import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:aves/services/cast/cast_hls_cache.dart'; import 'package:aves/services/cast/cast_server.dart'; import 'package:aves/services/cast/cast_fmp4_segmenter.dart'; class CastHlsOptimizationException implements Exception { final String message; const CastHlsOptimizationException(this.message); @override String toString() { return 'CastHlsOptimizationException: $message'; } } class CastHlsOptimizer { CastHlsOptimizer._(); static final CastHlsOptimizer instance = CastHlsOptimizer._(); 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; final Map> _pendingConversions = >{}; final Map> _readySignals = >{}; static void _log(String message) { debugPrint('[CAST HLS OPT] $message'); } bool isPending(int mediaId) { return _pendingConversions.containsKey(mediaId); } int get pendingCount { return _pendingConversions.length; } Future prepareFragmentedMp4(int mediaId) async { final pending = _pendingConversions[mediaId]; if (pending != null) { _log( 'REUSE PENDING ' 'mediaId=$mediaId ' 'pendingCount=${_pendingConversions.length}', ); return pending; } final readyCompleter = Completer(); _readySignals[mediaId] = readyCompleter; final completer = Completer(); final conversion = completer.future; _pendingConversions[mediaId] = conversion; Future runConversion() async { try { final file = await _convertFragmentedMp4( mediaId, readyCompleter, ); completer.complete(file); } catch (error, stackTrace) { completer.completeError(error, stackTrace); } } unawaited(runConversion()); _log( 'PENDING ADD ' 'mediaId=$mediaId ' 'pendingCount=${_pendingConversions.length}', ); try { return await conversion; } finally { if (identical(_pendingConversions[mediaId], conversion)) { final removed = _pendingConversions.remove(mediaId); _log( 'PENDING REMOVE ' 'mediaId=$mediaId ' 'removed=${removed != null} ' 'pendingCount=${_pendingConversions.length}', ); } final readySignal = _readySignals[mediaId]; if (identical(readySignal, readyCompleter)) { _readySignals.remove(mediaId); } } } Future prepareUntilReady(int mediaId) async { final isAlreadyReady = await castHlsCache.isReadyForPlayback( mediaId, minimumSegments: 3, ); if (isAlreadyReady) { final url = castServer.buildHlsUrl(mediaId); _log( 'READY CACHE HIT ' 'mediaId=$mediaId ' 'url=$url', ); return url; } final completedFuture = prepareFragmentedMp4( mediaId, ); completedFuture.ignore(); final readyCompleter = _readySignals[mediaId]; if (readyCompleter == null) { throw CastHlsOptimizationException( 'Segnale HLS ready non disponibile ' 'mediaId=$mediaId', ); } _log( 'READY WAIT ' 'mediaId=$mediaId', ); await readyCompleter.future; final ready = await castHlsCache.isReadyForPlayback( mediaId, minimumSegments: 3, ); if (!ready) { throw CastHlsOptimizationException( 'HLS segnalato ready ma file insufficienti ' 'mediaId=$mediaId', ); } final url = castServer.buildHlsUrl(mediaId); _log( 'READY END ' 'mediaId=$mediaId ' 'url=$url', ); return url; } Future _convertFragmentedMp4( int mediaId, Completer readyCompleter, ) async { final stopwatch = Stopwatch()..start(); _log( 'PREPARE START ' 'mediaId=$mediaId', ); final sourcePath = await castServer.resolveFilePathForMediaId( mediaId, ); if (sourcePath == null || sourcePath.isEmpty) { throw CastHlsOptimizationException( 'Percorso originale non disponibile per mediaId=$mediaId', ); } final sourceFile = File(sourcePath); if (!await sourceFile.exists()) { throw CastHlsOptimizationException( 'Video originale non trovato: $sourcePath', ); } final sourceBytes = await sourceFile.length(); if (sourceBytes <= 0) { throw CastHlsOptimizationException( 'Video originale vuoto: $sourcePath', ); } await castHlsCache.resetSession(mediaId); final outputPath = await castHlsCache.getWorkingMp4Path( mediaId, ); final outputFile = File(outputPath); final segmenter = CastFmp4Segmenter( mediaId: mediaId, sourcePath: outputPath, minimumReadySegments: 3, pollingInterval: const Duration(milliseconds: 150), onReady: () { if (!readyCompleter.isCompleted) { readyCompleter.complete(); _log( 'READY ' 'mediaId=$mediaId ' 'url=${castServer.buildHlsUrl(mediaId)}', ); } }, ); final segmenterFuture = segmenter.start(); /* * Il risultato verrà comunque atteso più avanti. * ignore() installa subito un gestore dell'errore, evitando che un errore * anticipato del segmenter venga considerato non gestito da Dart. */ segmenterFuture.ignore(); _log( 'SEGMENTER START ' 'mediaId=$mediaId ' 'sourcePath=$outputPath', ); _log( 'CONVERT START ' 'mediaId=$mediaId ' 'sourceBytes=$sourceBytes ' 'sourcePath=$sourcePath ' 'outputPath=$outputPath ' 'maximumWidth=$maximumWidth ' 'maximumHeight=$maximumHeight ' 'targetVideoBitrate=$targetVideoBitrate ' 'maximumVideoBitrate=$maximumVideoBitrate ' 'targetAudioBitrate=$targetAudioBitrate ' 'fragmentDurationMs=$fragmentDurationMs', ); var segmentationCompleted = false; try { final result = await _channel.invokeMapMethod( 'convertToMp4', { 'mediaId': mediaId, 'inputPath': sourcePath, 'outputPath': outputPath, 'container': 'mp4', 'videoCodec': 'h264', 'audioCodec': 'aac', 'maximumWidth': maximumWidth, 'maximumHeight': maximumHeight, 'targetVideoBitrate': targetVideoBitrate, 'maximumVideoBitrate': maximumVideoBitrate, 'targetAudioBitrate': targetAudioBitrate, 'fastStart': false, 'fragmentedMp4': true, 'fragmentDurationMs': fragmentDurationMs, }, ); final success = result?['success'] == true; if (!success) { throw CastHlsOptimizationException( 'Conversione fragmented MP4 fallita ' 'mediaId=$mediaId ' 'errorCode=${result?['errorCode'] ?? '-'} ' 'error=${result?['error'] ?? 'errore sconosciuto'}', ); } segmenter.notifyConversionCompleted(); await segmenterFuture; segmentationCompleted = true; final initializationReady = await castHlsCache.hasInitializationSegment(mediaId); final publishedSegments = await castHlsCache.countPublishedSegments(mediaId); _log( 'SEGMENTER END ' 'mediaId=$mediaId ' 'result=success ' 'initializationReady=$initializationReady ' 'publishedSegments=$publishedSegments ' 'ready=${segmenter.isReady}', ); if (!initializationReady) { throw CastHlsOptimizationException( 'Segmento di inizializzazione HLS non prodotto ' 'mediaId=$mediaId', ); } if (publishedSegments < 3) { throw CastHlsOptimizationException( 'Segmenti HLS insufficienti ' 'mediaId=$mediaId ' 'publishedSegments=$publishedSegments', ); } if (!await outputFile.exists()) { throw CastHlsOptimizationException( 'Media3 non ha creato fragmented MP4: $outputPath', ); } final outputBytes = await outputFile.length(); if (outputBytes <= 0) { throw CastHlsOptimizationException( 'Media3 ha creato fragmented MP4 vuoto: $outputPath', ); } stopwatch.stop(); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=success ' 'sourceBytes=$sourceBytes ' 'outputBytes=$outputBytes ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'path=$outputPath', ); return outputFile; } on MissingPluginException catch (error, stackTrace) { stopwatch.stop(); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=missing-plugin ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'error=$error', ); debugPrint( '[CAST HLS OPT] MISSING PLUGIN STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); rethrow; } on PlatformException catch (error, stackTrace) { stopwatch.stop(); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=platform-error ' 'code=${error.code} ' 'message=${error.message ?? '-'} ' 'details=${error.details ?? '-'} ' 'elapsedMs=${stopwatch.elapsedMilliseconds}', ); debugPrint( '[CAST HLS OPT] PLATFORM STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); throw CastHlsOptimizationException( 'Errore Media3 ' 'code=${error.code} ' 'message=${error.message ?? '-'}', ); } catch (error, stackTrace) { stopwatch.stop(); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=error ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'error=$error', ); debugPrint( '[CAST HLS OPT] CONVERT STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); if (error is CastHlsOptimizationException) { rethrow; } throw CastHlsOptimizationException( 'Conversione fragmented MP4 fallita: $error', ); } finally { if (!segmentationCompleted) { segmenter.requestStop(); try { await segmenterFuture; } catch (error) { _log( 'SEGMENTER END ' 'mediaId=$mediaId ' 'result=stopped-after-error ' 'error=$error', ); } } } } Future getWorkingFile(int mediaId) async { final path = await castHlsCache.getWorkingMp4Path(mediaId); final file = File(path); if (!await file.exists()) { return null; } if (await file.length() <= 0) { return null; } return file; } Future reset(int mediaId) async { final pending = isPending(mediaId); await castHlsCache.resetSession(mediaId); _log( 'RESET ' 'mediaId=$mediaId ' 'conversionPending=$pending', ); } } final castHlsOptimizer = CastHlsOptimizer.instance;