import 'dart:async'; import 'dart:io'; import 'dart:math' as math; 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_video_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 _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, { required int durationMillis, }) async { final existing = _runs[mediaId]; if (existing != null) { CastProgressiveTsOptimizer._log('REUSE mediaId=$mediaId'); return existing.session; } final run = _ProgressiveTsRun( mediaId: mediaId, durationMillis: durationMillis, channel: _channel, onFinished: () => _runs.remove(mediaId), ); _runs[mediaId] = run; run.start(); return run.session; } Future prepareUntilReady( int mediaId, { required int durationMillis, }) 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, durationMillis: durationMillis, ); 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; /* * Durata totale originale. Viene usata per costruire, * dopo i primi tre segmenti, una playlist VOD anticipata. */ final int durationMillis; final MethodChannel channel; final VoidCallback _onFinished; final Completer _readyCompleter = Completer(); final Completer _completedCompleter = Completer(); final List _pendingAudio = []; final List _pendingVideo = []; final List _segmentDurationsSeconds = []; /* * Durate congelate quando viene pubblicata per la prima volta * la VOD anticipata. Non devono cambiare durante la sessione, * perché Chromecast può mantenere in cache il primo manifest. */ List? _anticipatedDurationsSeconds; final Stopwatch _stopwatch = Stopwatch(); CastFmp4ProgressiveReader? _reader; bool _stopRequested = false; bool _conversionCompleted = false; int _publishedSegments = 0; int _publishedVideoSamples = 0; int _publishedAudioSamples = 0; // Media3 usa normalmente trackId=1 per l'audio e trackId=2 per il video. // Negli output video-only, però, la sola traccia video riceve trackId=1. bool _videoOnlyOutput = false; int _videoContinuityCounter = 0; int _audioContinuityCounter = 0; int _patContinuityCounter = 0; int _pmtContinuityCounter = 0; late String _workingPath; late String _playlistPath; _ProgressiveTsRun({ required this.mediaId, required this.durationMillis, 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', ); } final trackInspection = await channel.invokeMapMethod( 'inspectMediaTracks', { 'inputPath': sourcePath, }, ); final inspectionSucceeded = trackInspection?['success'] == true; final sourceHasVideo = trackInspection?['hasVideo'] == true; final sourceHasAudio = trackInspection?['hasAudio'] == true; if (!inspectionSucceeded || !sourceHasVideo) { throw CastProgressiveTsException( 'Ispezione tracce non valida ' 'mediaId=$mediaId ' 'result=$trackInspection', ); } _videoOnlyOutput = !sourceHasAudio; CastProgressiveTsOptimizer._log( 'SOURCE TRACKS ' 'mediaId=$mediaId ' 'hasVideo=$sourceHasVideo ' 'hasAudio=$sourceHasAudio ' 'videoOnly=$_videoOnlyOutput ' 'videoTracks=${trackInspection?['videoTrackCount'] ?? '-'} ' 'audioTracks=${trackInspection?['audioTrackCount'] ?? '-'} ' 'mimeTypes=${trackInspection?['mimeTypes'] ?? const []}', ); _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', ); } final audioMimeType = conversion?['audioMimeType']?.toString().trim(); _videoOnlyOutput = _videoOnlyOutput || audioMimeType == null || audioMimeType.isEmpty; if (_videoOnlyOutput && _pendingVideo.isEmpty && _pendingAudio.isNotEmpty) { final remappedSampleCount = _pendingAudio.length; _pendingVideo.addAll(_pendingAudio); _pendingAudio.clear(); CastProgressiveTsOptimizer._log( 'VIDEO-ONLY TRACK REMAP ' 'mediaId=$mediaId ' 'remappedSamples=$remappedSampleCount ' 'pendingVideo=${_pendingVideo.length} ' 'audioMimeType=${audioMimeType ?? '-'}', ); } _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); /* * Tutti i segmenti HLS finali sono stati pubblicati. * Conserviamo playlist, segmenti, sessione e complete.flag; * eliminiamo soltanto i file intermedi e l'eventuale MP4 * ottimizzato dello stesso mediaId. */ await castHlsCache.removeCompletedWorkingFiles( mediaId, ); await castVideoCache.invalidate(mediaId); if (!_readyCompleter.isCompleted) { // Un video breve può terminare prima di raggiungere la normale // soglia di buffer. A conversione completata verifica tutti i // segmenti prodotti, fino alla soglia ordinaria di READY. await _completeReady( minimumSegments: math.min( CastProgressiveTsOptimizer.minimumReadySegments, _publishedSegments, ), ); } _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; } /* * Un frammento fMP4, soprattutto quello finale, può contenere * soltanto una delle due tracce. * * Non usare singleWhere(), perché l'assenza temporanea della * traccia audio o video non è un errore. */ var audioTracks = 0; var videoTracks = 0; var addedAudioSamples = 0; var addedVideoSamples = 0; for (final track in fragment.tracks) { final isVideoTrack = track.trackId == CastProgressiveTsOptimizer._videoTrackId || (_videoOnlyOutput && track.trackId == CastProgressiveTsOptimizer._audioTrackId); if (isVideoTrack) { videoTracks++; addedVideoSamples += track.samples.length; _pendingVideo.addAll(track.samples); } else if (track.trackId == CastProgressiveTsOptimizer._audioTrackId) { audioTracks++; addedAudioSamples += track.samples.length; _pendingAudio.addAll(track.samples); } } CastProgressiveTsOptimizer._log( 'FRAGMENT ' 'mediaId=$mediaId ' 'audioTracks=$audioTracks ' 'videoTracks=$videoTracks ' 'audioSamples=$addedAudioSamples ' 'videoSamples=$addedVideoSamples ' 'pendingAudio=${_pendingAudio.length} ' 'pendingVideo=${_pendingVideo.length}', ); /* * I sample della traccia mancante possono arrivare con un * frammento successivo. Pubblica ciò che è già coperto da * entrambe le timeline. */ await _publishAvailableSegments(); } Future _publishAvailableSegments() async { while (!_stopRequested) { final videoOnly = _videoOnlyOutput && _pendingAudio.isEmpty; if (_pendingVideo.isEmpty || (!videoOnly && _pendingAudio.isEmpty)) { return; } /* * Ogni segmento MPEG-TS deve iniziare con un keyframe. * * Dopo la pubblicazione precedente, il primo sample video * rimasto deve quindi essere il keyframe che apre il nuovo * segmento. */ final firstVideoSample = _pendingVideo.first; if (!firstVideoSample.isSync) { /* * Durante l'avvio possono essere presenti sample anteriori * al primo keyframe utilizzabile. Li scartiamo soltanto prima * del primo segmento. * * Per segmenti successivi questa condizione indica invece * una discontinuità inattesa e non bisogna produrre un TS * non indipendente. */ if (_publishedSegments == 0) { final firstSyncIndex = _pendingVideo.indexWhere( (sample) => sample.isSync, ); if (firstSyncIndex < 0) { return; } final firstSyncTime = _pendingVideo[firstSyncIndex].decodeTime; _pendingVideo.removeWhere( (sample) => sample.decodeTime < firstSyncTime, ); final audioStartAtSync = (firstSyncTime * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale; _pendingAudio.removeWhere( (sample) => sample.decodeTime + sample.duration <= audioStartAtSync, ); continue; } throw CastProgressiveTsException( 'Il segmento $_publishedSegments non ha un keyframe iniziale', ); } final videoStart = firstVideoSample.decodeTime; /* * Il segmento deve durare almeno circa 3 secondi, ma il confine * effettivo viene spostato al primo keyframe successivo. * * Quel keyframe non viene incluso nel segmento corrente: * resterà in _pendingVideo e aprirà il segmento successivo. */ final targetVideoEnd = videoStart + CastProgressiveTsOptimizer._segmentDurationVideoTicks; final nextSyncIndex = _pendingVideo.indexWhere( (sample) => sample.isSync && sample.decodeTime >= targetVideoEnd, 1, ); if (nextSyncIndex < 0) { /* * Il keyframe che deve aprire il prossimo segmento non è * ancora arrivato dal reader fMP4. */ return; } final videoEnd = _pendingVideo[nextSyncIndex].decodeTime; final audioStart = (videoStart * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale; final audioEnd = (videoEnd * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale; /* * Aspetta finché l'audio copre interamente lo stesso intervallo * temporale del segmento video. */ final audioCovered = videoOnly || _pendingAudio.any( (sample) => sample.decodeTime + sample.duration >= audioEnd, ); if (!audioCovered) { return; } await _publishSegment( segmentIndex: _publishedSegments, videoStart: videoStart, videoEnd: videoEnd, audioStart: audioStart, audioEnd: audioEnd, ); } } Future _publishFinalPartialSegment() async { if (_pendingVideo.isEmpty) { return; } /* * Alla fine non serve un keyframe successivo per determinare * videoEnd, ma il segmento finale deve comunque iniziare con * un keyframe. */ final firstVideoSample = _pendingVideo.first; if (!firstVideoSample.isSync) { throw CastProgressiveTsException( 'Il segmento finale $_publishedSegments ' 'non inizia con keyframe', ); } final videoStart = firstVideoSample.decodeTime; final videoEnd = _pendingVideo .map( (sample) => sample.decodeTime + sample.duration, ) .reduce( (a, b) => a > b ? a : b, ); if (videoEnd <= videoStart) { return; } final audioStart = (videoStart * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale; final audioEnd = (videoEnd * CastProgressiveTsOptimizer._audioTimescale) ~/ CastProgressiveTsOptimizer._videoTimescale; final videoOnly = _videoOnlyOutput && _pendingAudio.isEmpty; final hasAudio = _pendingAudio.any( (sample) => sample.decodeTime >= audioStart && sample.decodeTime < audioEnd, ); if (!videoOnly && !hasAudio) { return; } await _publishSegment( segmentIndex: _publishedSegments, 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) { throw CastProgressiveTsException( 'Sample video 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({ int minimumSegments = CastProgressiveTsOptimizer.minimumReadySegments, }) async { if (minimumSegments < 1) { throw ArgumentError.value( minimumSegments, 'minimumSegments', 'Deve essere almeno 1', ); } final ready = await castHlsCache.isTransportStreamReadyForPlayback( mediaId, minimumSegments: minimumSegments, ); if (!ready) { throw CastProgressiveTsException( 'Segnale READY senza file sufficienti ' 'mediaId=$mediaId ' 'minimumSegments=$minimumSegments ' 'publishedSegments=$_publishedSegments', ); } 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 ' 'minimumSegments=$minimumSegments ' 'conversionCompleted=$_conversionCompleted ' 'elapsedMs=${_stopwatch.elapsedMilliseconds} ' 'url=${value.url}', ); } List _buildAnticipatedDurations() { if (_segmentDurationsSeconds.isEmpty) { throw const CastProgressiveTsException( 'Impossibile stimare la VOD senza segmenti reali', ); } if (durationMillis <= 0) { throw CastProgressiveTsException( 'Durata totale non valida per mediaId=$mediaId: ' '$durationMillis ms', ); } final observedCount = math.min( CastProgressiveTsOptimizer.minimumReadySegments, _segmentDurationsSeconds.length, ); final observedDurations = _segmentDurationsSeconds.take(observedCount).toList(growable: false); /* * Il primo segmento può essere più corto per l'allineamento * iniziale al primo keyframe. * * La mediana è più robusta della media e rappresenta meglio * la durata normale dei segmenti successivi. */ final sortedObservedDurations = List.of( observedDurations, )..sort(); final middleIndex = sortedObservedDurations.length ~/ 2; final double typicalDuration; if (sortedObservedDurations.length.isOdd) { typicalDuration = sortedObservedDurations[middleIndex]; } else { typicalDuration = (sortedObservedDurations[middleIndex - 1] + sortedObservedDurations[middleIndex]) / 2; } if (!typicalDuration.isFinite || typicalDuration <= 0) { throw CastProgressiveTsException( 'Durata tipica segmento non valida: ' '$typicalDuration', ); } final totalDurationSeconds = durationMillis / 1000.0; final predictedCount = math.max( _publishedSegments, (totalDurationSeconds / typicalDuration).ceil(), ); final predicted = []; for (var index = 0; index < predictedCount; index++) { if (index < _segmentDurationsSeconds.length) { predicted.add( _segmentDurationsSeconds[index], ); } else { predicted.add(typicalDuration); } } /* * Corregge l'ultimo EXTINF affinché la somma totale * corrisponda alla durata originale dichiarata. */ final durationBeforeLast = predicted .take(predicted.length - 1) .fold( 0, (total, value) => total + value, ); final lastDuration = totalDurationSeconds - durationBeforeLast; if (lastDuration > 0) { predicted[predicted.length - 1] = lastDuration; } CastProgressiveTsOptimizer._log( 'ANTICIPATED VOD ESTIMATE ' 'mediaId=$mediaId ' 'durationMs=$durationMillis ' 'observed=$observedCount ' 'published=$_publishedSegments ' 'typicalSeconds=${typicalDuration.toStringAsFixed(6)} ' 'predicted=${predicted.length}', ); return List.unmodifiable( predicted, ); } Future _writePlaylist({ required bool completed, }) async { /* * Prima della soglia READY pubblica una normale EVENT * progressiva contenente solo i segmenti reali. * * Al raggiungimento della soglia congela una VOD anticipata * completa. Le richieste ai segmenti futuri saranno trattenute * dal CastServer finché i file non verranno prodotti. * * Alla fine sostituisce la previsione con la VOD reale. */ final bool anticipated = !completed && _publishedSegments >= CastProgressiveTsOptimizer.minimumReadySegments; final List playlistDurations; if (completed) { playlistDurations = List.of( _segmentDurationsSeconds, ); } else if (anticipated) { _anticipatedDurationsSeconds ??= _buildAnticipatedDurations(); playlistDurations = _anticipatedDurationsSeconds!; } else { playlistDurations = List.of( _segmentDurationsSeconds, ); } final maximumDuration = playlistDurations.isEmpty ? 3.0 : playlistDurations.reduce( math.max, ); final targetDuration = math.max( 1, maximumDuration.ceil(), ); final buffer = StringBuffer() ..writeln('#EXTM3U') ..writeln('#EXT-X-VERSION:6') ..writeln( '#EXT-X-TARGETDURATION:$targetDuration', ) ..writeln('#EXT-X-MEDIA-SEQUENCE:0'); if (anticipated || completed) { buffer ..writeln( '#EXT-X-START:TIME-OFFSET=0,PRECISE=YES', ) ..writeln( '#EXT-X-PLAYLIST-TYPE:VOD', ); } else { buffer.writeln( '#EXT-X-PLAYLIST-TYPE:EVENT', ); } buffer.writeln( '#EXT-X-INDEPENDENT-SEGMENTS', ); for (var index = 0; index < playlistDurations.length; index++) { final duration = playlistDurations[index].toStringAsFixed(6); buffer ..writeln('#EXTINF:$duration,') ..writeln( 'segment_${index.toString().padLeft(5, '0')}.ts', ); } if (anticipated || completed) { buffer.writeln('#EXT-X-ENDLIST'); } await castHlsCache.writePlaylistAtomically( mediaId, buffer.toString(), ); CastProgressiveTsOptimizer._log( 'PLAYLIST ' 'mediaId=$mediaId ' 'mode=${completed ? 'final-vod' : anticipated ? 'anticipated-vod' : 'event'} ' 'realSegments=$_publishedSegments ' 'declaredSegments=${playlistDurations.length} ' 'targetDuration=$targetDuration', ); } 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;