import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:aves/services/cast/cast_fmp4_fragment_parser.dart'; import 'package:aves/services/cast/cast_fmp4_progressive_reader.dart'; import 'package:aves/services/cast/cast_hls_cache.dart'; import 'package:aves/services/cast/cast_server.dart'; class CastProgressiveTsException implements Exception { final String message; const CastProgressiveTsException(this.message); @override String toString() => 'CastProgressiveTsException: $message'; } class CastProgressiveTsReady { final int mediaId; final String playlistPath; final String url; final int publishedSegmentCount; final Duration elapsed; const CastProgressiveTsReady({ required this.mediaId, required this.playlistPath, required this.url, required this.publishedSegmentCount, required this.elapsed, }); } class CastProgressiveTsResult { final int mediaId; final String playlistPath; final String workingFmp4Path; final int publishedSegmentCount; final int videoSampleCount; final int audioSampleCount; final Duration elapsed; const CastProgressiveTsResult({ required this.mediaId, required this.playlistPath, required this.workingFmp4Path, required this.publishedSegmentCount, required this.videoSampleCount, required this.audioSampleCount, required this.elapsed, }); } class CastProgressiveTsSession { final int mediaId; final Future ready; final Future completed; final void Function() _requestStop; const CastProgressiveTsSession._({ required this.mediaId, required this.ready, required this.completed, required void Function() requestStop, }) : _requestStop = requestStop; void requestStop() => _requestStop(); } class CastProgressiveTsOptimizer { CastProgressiveTsOptimizer._(); static final CastProgressiveTsOptimizer instance = CastProgressiveTsOptimizer._(); static const MethodChannel _channel = MethodChannel( 'aves/cast_video_optimizer', ); static const int maximumWidth = 1920; static const int maximumHeight = 1080; static const int targetVideoBitrate = 5000000; static const int maximumVideoBitrate = 6000000; static const int targetAudioBitrate = 160000; static const int fragmentDurationMs = 2000; static const int minimumReadySegments = 3; static const int _audioTrackId = 1; static const int _videoTrackId = 2; static const int _audioTimescale = 48000; static const int _videoTimescale = 90000; static const int _segmentDurationAudioTicks = 144000; static const int _segmentDurationVideoTicks = 270000; final Map _runs = {}; static void _log(String message) { debugPrint('[CAST PROGRESSIVE TS] $message'); } bool isPending(int mediaId) => _runs.containsKey(mediaId); int get pendingCount => _runs.length; Future start(int mediaId) async { final existing = _runs[mediaId]; if (existing != null) { CastProgressiveTsOptimizer._log('REUSE mediaId=$mediaId'); return existing.session; } final run = _ProgressiveTsRun( mediaId: mediaId, channel: _channel, onFinished: () => _runs.remove(mediaId), ); _runs[mediaId] = run; run.start(); return run.session; } Future prepareUntilReady(int mediaId) async { final alreadyComplete = await castHlsCache.isComplete( mediaId, ); final alreadyReady = alreadyComplete && await castHlsCache.isTransportStreamReadyForPlayback( mediaId, minimumSegments: minimumReadySegments, ); if (alreadyReady) { await castServer.ensureStarted(); final url = castServer.buildHlsUrl( mediaId, ); _log( 'CACHE HIT ' 'mediaId=$mediaId ' 'url=$url', ); return url; } final session = await start( mediaId, ); final ready = await session.ready; return ready.url; } Future reset(int mediaId) async { final run = _runs[mediaId]; run?.requestStop(); if (run != null) { try { await run.completed; } catch (_) { // The session is being explicitly discarded. } } await castHlsCache.resetSession(mediaId); } } class _ProgressiveTsRun { final int mediaId; final MethodChannel channel; final VoidCallback _onFinished; final Completer _readyCompleter = Completer(); final Completer _completedCompleter = Completer(); final List _pendingAudio = []; final List _pendingVideo = []; final List _segmentDurationsSeconds = []; final Stopwatch _stopwatch = Stopwatch(); CastFmp4ProgressiveReader? _reader; bool _stopRequested = false; bool _conversionCompleted = false; int _publishedSegments = 0; int _publishedVideoSamples = 0; int _publishedAudioSamples = 0; int _videoContinuityCounter = 0; int _audioContinuityCounter = 0; int _patContinuityCounter = 0; int _pmtContinuityCounter = 0; late String _workingPath; late String _playlistPath; _ProgressiveTsRun({ required this.mediaId, required this.channel, required VoidCallback onFinished, }) : _onFinished = onFinished; late final CastProgressiveTsSession session = CastProgressiveTsSession._( mediaId: mediaId, ready: _readyCompleter.future, completed: _completedCompleter.future, requestStop: requestStop, ); Future get completed => _completedCompleter.future; void start() { unawaited(_run()); } void requestStop() { _stopRequested = true; _reader?.requestStop(); } Future _run() async { _stopwatch.start(); Future? readerFuture; try { await castHlsCache.resetSession(mediaId); await castServer.ensureStarted(); final sourcePath = await castServer.resolveFilePathForMediaId(mediaId); if (sourcePath == null || sourcePath.isEmpty) { throw CastProgressiveTsException( 'Percorso originale non disponibile per mediaId=$mediaId', ); } final sourceFile = File(sourcePath); if (!await sourceFile.exists() || await sourceFile.length() <= 0) { throw CastProgressiveTsException( 'Video originale non valido: $sourcePath', ); } _workingPath = await castHlsCache.getWorkingMp4Path(mediaId); _playlistPath = await castHlsCache.getPlaylistPath(mediaId); _reader = CastFmp4ProgressiveReader( sourcePath: _workingPath, pollingInterval: const Duration(milliseconds: 50), onFragment: _onFragment, ); readerFuture = _reader!.start(); readerFuture.ignore(); CastProgressiveTsOptimizer._log( 'START mediaId=$mediaId source=$sourcePath working=$_workingPath', ); final conversion = await channel.invokeMapMethod( 'convertToMp4', { 'mediaId': mediaId, 'inputPath': sourcePath, 'outputPath': _workingPath, 'container': 'mp4', 'videoCodec': 'h264', 'audioCodec': 'aac', 'maximumWidth': CastProgressiveTsOptimizer.maximumWidth, 'maximumHeight': CastProgressiveTsOptimizer.maximumHeight, 'targetVideoBitrate': CastProgressiveTsOptimizer.targetVideoBitrate, 'maximumVideoBitrate': CastProgressiveTsOptimizer.maximumVideoBitrate, 'targetAudioBitrate': CastProgressiveTsOptimizer.targetAudioBitrate, 'fastStart': false, 'fragmentedMp4': true, 'fragmentDurationMs': CastProgressiveTsOptimizer.fragmentDurationMs, }, ); if (conversion?['success'] != true) { throw CastProgressiveTsException( 'Conversione Media3 non riuscita: $conversion', ); } _conversionCompleted = true; _reader!.notifyConversionCompleted(); await readerFuture; if (_stopRequested) { throw const CastProgressiveTsException('Sessione interrotta'); } await _publishAvailableSegments(); if (_pendingVideo.isNotEmpty) { await _publishFinalPartialSegment(); } if (_publishedSegments == 0) { throw const CastProgressiveTsException( 'Nessun segmento MPEG-TS prodotto', ); } await _writePlaylist(completed: true); await castHlsCache.markComplete(mediaId); if (!_readyCompleter.isCompleted) { await _completeReady(); } _stopwatch.stop(); final result = CastProgressiveTsResult( mediaId: mediaId, playlistPath: _playlistPath, workingFmp4Path: _workingPath, publishedSegmentCount: _publishedSegments, videoSampleCount: _publishedVideoSamples, audioSampleCount: _publishedAudioSamples, elapsed: _stopwatch.elapsed, ); _completedCompleter.complete(result); CastProgressiveTsOptimizer._log( 'END mediaId=$mediaId result=success segments=$_publishedSegments ' 'videoSamples=$_publishedVideoSamples ' 'audioSamples=$_publishedAudioSamples ' 'elapsedMs=${_stopwatch.elapsedMilliseconds}', ); } catch (error, stackTrace) { if (_stopwatch.isRunning) { _stopwatch.stop(); } _reader?.requestStop(); final wrapped = error is CastProgressiveTsException ? error : CastProgressiveTsException('$error'); if (!_readyCompleter.isCompleted) { _readyCompleter.completeError(wrapped, stackTrace); } if (!_completedCompleter.isCompleted) { _completedCompleter.completeError(wrapped, stackTrace); } CastProgressiveTsOptimizer._log( 'END mediaId=$mediaId result=error ' 'elapsedMs=${_stopwatch.elapsedMilliseconds} error=$wrapped', ); } finally { _reader?.requestStop(); if (readerFuture != null) { try { await readerFuture; } catch (_) { // The main error has already been propagated. } } _onFinished(); } } Future _onFragment(Fmp4FragmentInfo fragment) async { if (_stopRequested) { return; } final audio = fragment.tracks.singleWhere( (track) => track.trackId == CastProgressiveTsOptimizer._audioTrackId, ); final video = fragment.tracks.singleWhere( (track) => track.trackId == CastProgressiveTsOptimizer._videoTrackId, ); _pendingAudio.addAll(audio.samples); _pendingVideo.addAll(video.samples); await _publishAvailableSegments(); } Future _publishAvailableSegments() async { while (!_stopRequested) { final segmentIndex = _publishedSegments; final videoStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationVideoTicks; final videoEnd = (segmentIndex + 1) * CastProgressiveTsOptimizer._segmentDurationVideoTicks; final audioStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationAudioTicks; final audioEnd = (segmentIndex + 1) * CastProgressiveTsOptimizer._segmentDurationAudioTicks; final videoCovered = _pendingVideo.any( (sample) => sample.decodeTime + sample.duration >= videoEnd, ); final audioCovered = _pendingAudio.any( (sample) => sample.decodeTime + sample.duration >= audioEnd, ); if (!videoCovered || !audioCovered) { return; } await _publishSegment( segmentIndex: segmentIndex, videoStart: videoStart, videoEnd: videoEnd, audioStart: audioStart, audioEnd: audioEnd, ); } } Future _publishFinalPartialSegment() async { final segmentIndex = _publishedSegments; final videoStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationVideoTicks; final audioStart = segmentIndex * CastProgressiveTsOptimizer._segmentDurationAudioTicks; final remainingVideo = _pendingVideo.where((sample) => sample.decodeTime >= videoStart).toList(growable: false); if (remainingVideo.isEmpty) { return; } final videoEnd = remainingVideo.map((sample) => sample.decodeTime + sample.duration).reduce((a, b) => a > b ? a : b); final durationVideoTicks = videoEnd - videoStart; final audioEnd = audioStart + ((durationVideoTicks * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale); if (!_pendingAudio.any( (sample) => sample.decodeTime >= audioStart && sample.decodeTime < audioEnd, )) { return; } await _publishSegment( segmentIndex: segmentIndex, videoStart: videoStart, videoEnd: videoEnd, audioStart: audioStart, audioEnd: audioEnd, ); } Future _publishSegment({ required int segmentIndex, required int videoStart, required int videoEnd, required int audioStart, required int audioEnd, }) async { final videoSamples = _pendingVideo .where( (sample) => sample.decodeTime >= videoStart && sample.decodeTime < videoEnd, ) .toList(growable: false); final audioSamples = _pendingAudio .where( (sample) => sample.decodeTime >= audioStart && sample.decodeTime < audioEnd, ) .toList(growable: false); if (videoSamples.isEmpty || audioSamples.isEmpty) { throw CastProgressiveTsException( 'Sample insufficienti per il segmento $segmentIndex', ); } if (!videoSamples.first.isSync) { throw CastProgressiveTsException( 'Il segmento $segmentIndex non inizia con keyframe', ); } final outputPath = await castHlsCache.getTransportStreamSegmentPath( mediaId, segmentIndex, ); final report = await channel.invokeMapMethod( 'writeFmp4FirstTsSegmentTest', { 'inputPath': _workingPath, 'outputPath': outputPath, 'segmentIndex': segmentIndex, 'videoStartDts': videoStart, 'videoEndDts': videoEnd, 'audioStartDts': audioStart, 'audioEndDts': audioEnd, 'initialVideoContinuityCounter': _videoContinuityCounter, 'initialAudioContinuityCounter': _audioContinuityCounter, 'patContinuityCounter': _patContinuityCounter, 'pmtContinuityCounter': _pmtContinuityCounter, 'audioSamples': audioSamples.map(_sampleToMap).toList(growable: false), 'videoSamples': videoSamples.map(_sampleToMap).toList(growable: false), }, ); if (report?['success'] != true) { throw CastProgressiveTsException( 'Writer TS fallito per segmento $segmentIndex: $report', ); } _videoContinuityCounter = (report!['nextVideoContinuityCounter'] as num).toInt(); _audioContinuityCounter = (report['nextAudioContinuityCounter'] as num).toInt(); _patContinuityCounter = (_patContinuityCounter + 1) & 0x0f; _pmtContinuityCounter = (_pmtContinuityCounter + 1) & 0x0f; _publishedVideoSamples += videoSamples.length; _publishedAudioSamples += audioSamples.length; _segmentDurationsSeconds.add( (videoEnd - videoStart) / CastProgressiveTsOptimizer._videoTimescale, ); _publishedSegments++; _pendingVideo.removeWhere((sample) => sample.decodeTime < videoEnd); _pendingAudio.removeWhere((sample) => sample.decodeTime < audioEnd); await _writePlaylist(completed: false); await _writeSession(); CastProgressiveTsOptimizer._log( 'SEGMENT mediaId=$mediaId index=$segmentIndex ' 'video=${videoSamples.length} audio=${audioSamples.length} ' 'bytes=${report['outputBytes']} ' 'published=$_publishedSegments', ); if (_publishedSegments >= CastProgressiveTsOptimizer.minimumReadySegments && !_readyCompleter.isCompleted) { await _completeReady(); } } Future _completeReady() async { final ready = await castHlsCache.isTransportStreamReadyForPlayback( mediaId, minimumSegments: CastProgressiveTsOptimizer.minimumReadySegments, ); if (!ready) { throw CastProgressiveTsException( 'Segnale READY senza file sufficienti mediaId=$mediaId', ); } final value = CastProgressiveTsReady( mediaId: mediaId, playlistPath: _playlistPath, url: castServer.buildHlsUrl(mediaId), publishedSegmentCount: _publishedSegments, elapsed: _stopwatch.elapsed, ); _readyCompleter.complete(value); CastProgressiveTsOptimizer._log( 'READY mediaId=$mediaId segments=$_publishedSegments ' 'elapsedMs=${_stopwatch.elapsedMilliseconds} url=${value.url}', ); } Future _writePlaylist({required bool completed}) async { final buffer = StringBuffer() ..writeln('#EXTM3U') ..writeln('#EXT-X-VERSION:3') ..writeln('#EXT-X-TARGETDURATION:3') ..writeln('#EXT-X-MEDIA-SEQUENCE:0') ..writeln( completed ? '#EXT-X-PLAYLIST-TYPE:VOD' : '#EXT-X-PLAYLIST-TYPE:EVENT', ) ..writeln('#EXT-X-INDEPENDENT-SEGMENTS'); for (var index = 0; index < _publishedSegments; index++) { final duration = _segmentDurationsSeconds[index].toStringAsFixed(6); buffer ..writeln('#EXTINF:$duration,') ..writeln('segment_${index.toString().padLeft(5, '0')}.ts'); } if (completed) { buffer.writeln('#EXT-X-ENDLIST'); } await castHlsCache.writePlaylistAtomically( mediaId, buffer.toString(), ); } Future _writeSession() async { await castHlsCache.writeSessionAtomically( mediaId, { 'format': 'mpeg-ts', 'mediaId': mediaId, 'publishedSegmentCount': _publishedSegments, 'videoSampleCount': _publishedVideoSamples, 'audioSampleCount': _publishedAudioSamples, 'ready': _readyCompleter.isCompleted, 'conversionCompleted': _conversionCompleted, 'updatedAt': DateTime.now().toIso8601String(), }, ); } static Map _sampleToMap(Fmp4SampleInfo sample) { return { 'offset': sample.absoluteOffset, 'size': sample.size, 'dts': sample.decodeTime, 'pts': sample.presentationTime, 'duration': sample.duration, 'sync': sample.isSync, }; } } final castProgressiveTsOptimizer = CastProgressiveTsOptimizer.instance;