// lib/services/cast/cast_video_optimizer.dart import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:aves/services/cast/cast_video_cache.dart'; import 'package:aves/services/cast/cast_server.dart'; class CastVideoOptimizationException implements Exception { final String message; const CastVideoOptimizationException(this.message); @override String toString() { return 'CastVideoOptimizationException: $message'; } } class CastVideoOptimizer { CastVideoOptimizer._(); static final CastVideoOptimizer instance = CastVideoOptimizer._(); /* * Canale che verrà implementato nel plugin Android. * * Per ora il metodo Dart non viene ancora richiamato dal controller, * quindi l'assenza dell'implementazione Kotlin non modifica il * funzionamento attuale del Cast. */ static const MethodChannel _channel = MethodChannel( 'aves/cast_video_optimizer', ); /* * Impostazioni iniziali della variante video Cast. * * Il bitrate video massimo sarà circa 6 Mbps. * Il bitrate obiettivo sarà circa 5 Mbps. * L'audio verrà convertito in AAC a 160 kbps. */ static const int targetWidth = 1920; static const int targetHeight = 1080; static const int targetVideoBitrate = 5000000; static const int maximumVideoBitrate = 6000000; static const int targetAudioBitrate = 160000; /* * Conversioni attualmente in corso. * * Se più parti dell'app richiedono contemporaneamente la conversione * dello stesso media, viene restituito lo stesso Future. */ final Map> _pendingConversions = >{}; static void _log(String message) { debugPrint('[CAST VIDEO OPT] $message'); } bool isPending(int mediaId) { return _pendingConversions.containsKey(mediaId); } int get pendingCount { return _pendingConversions.length; } Future prepareForMediaId(int mediaId) async { final stopwatch = Stopwatch()..start(); _log( 'PREPARE MEDIA START ' 'mediaId=$mediaId', ); final sourcePath = await castServer.resolveFilePathForMediaId( mediaId, ); if (sourcePath == null || sourcePath.isEmpty) { stopwatch.stop(); _log( 'PREPARE MEDIA END ' 'mediaId=$mediaId ' 'result=no-source-path ' 'elapsedMs=${stopwatch.elapsedMilliseconds}', ); throw CastVideoOptimizationException( 'Percorso originale non disponibile per mediaId=$mediaId', ); } try { final convertedFile = await prepare( mediaId: mediaId, sourcePath: sourcePath, ); stopwatch.stop(); final outputBytes = await convertedFile.length(); _log( 'PREPARE MEDIA END ' 'mediaId=$mediaId ' 'result=success ' 'outputBytes=$outputBytes ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'path=${convertedFile.path}', ); return convertedFile; } catch (error) { stopwatch.stop(); _log( 'PREPARE MEDIA END ' 'mediaId=$mediaId ' 'result=error ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'error=$error', ); rethrow; } } /// Restituisce la variante MP4 già presente in cache. /// /// Se la variante non esiste, avvia una sola conversione per mediaId. /// Eventuali richieste simultanee riutilizzano lo stesso Future. Future prepare({ required int mediaId, required String sourcePath, }) async { final cachedFile = await castVideoCache.getValidFile(mediaId); if (cachedFile != null) { _log( 'CACHE HIT ' 'mediaId=$mediaId ' 'path=${cachedFile.path}', ); return cachedFile; } final pendingConversion = _pendingConversions[mediaId]; if (pendingConversion != null) { _log( 'REUSE PENDING ' 'mediaId=$mediaId ' 'pendingCount=${_pendingConversions.length}', ); return pendingConversion; } /* * Inserisce il Future nella mappa prima di avviare la conversione. * Eventuali richieste simultanee per lo stesso mediaId riutilizzeranno * immediatamente la stessa operazione. */ final conversionCompleter = Completer(); final conversion = conversionCompleter.future; _pendingConversions[mediaId] = conversion; Future runConversion() async { try { final convertedFile = await _convert( mediaId: mediaId, sourcePath: sourcePath, ); conversionCompleter.complete(convertedFile); } catch (error, stackTrace) { conversionCompleter.completeError( error, stackTrace, ); } } unawaited(runConversion()); _log( 'PENDING ADD ' 'mediaId=$mediaId ' 'pendingCount=${_pendingConversions.length}', ); try { return await conversion; } finally { if (identical(_pendingConversions[mediaId], conversion)) { final removedConversion = _pendingConversions.remove(mediaId); _log( 'PENDING REMOVE ' 'mediaId=$mediaId ' 'removed=${removedConversion != null} ' 'pendingCount=${_pendingConversions.length}', ); } } } Future _convert({ required int mediaId, required String sourcePath, }) async { final stopwatch = Stopwatch()..start(); final sourceFile = File(sourcePath); if (!await sourceFile.exists()) { throw CastVideoOptimizationException( 'Video originale non trovato: $sourcePath', ); } final sourceStat = await sourceFile.stat(); if (sourceStat.size <= 0) { throw CastVideoOptimizationException( 'Video originale vuoto: $sourcePath', ); } final temporaryPath = await castVideoCache.getTemporaryPath(mediaId); final finalPath = await castVideoCache.getFinalPath(mediaId); final temporaryFile = File(temporaryPath); /* * Elimina soltanto un'eventuale conversione parziale precedente. * Il file definitivo è già stato controllato da getValidFile(). */ if (await temporaryFile.exists()) { await temporaryFile.delete(); _log( 'PARTIAL REMOVED ' 'mediaId=$mediaId ' 'path=$temporaryPath', ); } _log( 'CONVERT START ' 'mediaId=$mediaId ' 'sourceBytes=${sourceStat.size} ' 'sourcePath=$sourcePath ' 'temporaryPath=$temporaryPath ' 'finalPath=$finalPath ' 'width=$targetWidth ' 'height=$targetHeight ' 'targetVideoBitrate=$targetVideoBitrate ' 'maximumVideoBitrate=$maximumVideoBitrate ' 'targetAudioBitrate=$targetAudioBitrate', ); try { final result = await _channel.invokeMapMethod( 'convertToMp4', { 'mediaId': mediaId, 'inputPath': sourcePath, 'outputPath': temporaryPath, 'container': 'mp4', 'videoCodec': 'h264', 'audioCodec': 'aac', 'maximumWidth': targetWidth, 'maximumHeight': targetHeight, 'targetVideoBitrate': targetVideoBitrate, 'maximumVideoBitrate': maximumVideoBitrate, 'targetAudioBitrate': targetAudioBitrate, 'fastStart': true, }, ); final success = result?['success'] == true; if (!success) { final error = result?['error']; final errorCode = result?['errorCode']; throw CastVideoOptimizationException( 'Conversione nativa fallita ' 'mediaId=$mediaId ' 'errorCode=${errorCode ?? '-'} ' 'error=${error ?? 'errore sconosciuto'}', ); } if (!await temporaryFile.exists()) { throw CastVideoOptimizationException( 'La conversione è terminata senza creare il file temporaneo: ' '$temporaryPath', ); } final temporaryLength = await temporaryFile.length(); if (temporaryLength <= 0) { throw CastVideoOptimizationException( 'La conversione ha creato un file temporaneo vuoto: ' '$temporaryPath', ); } final committedFile = await castVideoCache.commitTemporaryFile( mediaId, ); final committedLength = await committedFile.length(); stopwatch.stop(); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=success ' 'sourceBytes=${sourceStat.size} ' 'outputBytes=$committedLength ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'path=${committedFile.path}', ); return committedFile; } on MissingPluginException catch (error, stackTrace) { stopwatch.stop(); await _deleteTemporaryFile( mediaId: mediaId, temporaryFile: temporaryFile, ); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=missing-plugin ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'error=$error', ); debugPrint( '[CAST VIDEO OPT] MISSING PLUGIN STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); throw const CastVideoOptimizationException( 'Convertitore video Android non ancora disponibile', ); } on PlatformException catch (error, stackTrace) { stopwatch.stop(); await _deleteTemporaryFile( mediaId: mediaId, temporaryFile: temporaryFile, ); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=platform-error ' 'code=${error.code} ' 'message=${error.message ?? '-'} ' 'details=${error.details ?? '-'} ' 'elapsedMs=${stopwatch.elapsedMilliseconds}', ); debugPrint( '[CAST VIDEO OPT] PLATFORM STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); throw CastVideoOptimizationException( 'Errore del convertitore Android ' 'code=${error.code} ' 'message=${error.message ?? '-'}', ); } catch (error, stackTrace) { stopwatch.stop(); await _deleteTemporaryFile( mediaId: mediaId, temporaryFile: temporaryFile, ); _log( 'CONVERT END ' 'mediaId=$mediaId ' 'result=error ' 'elapsedMs=${stopwatch.elapsedMilliseconds} ' 'error=$error', ); debugPrint( '[CAST VIDEO OPT] CONVERT STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); if (error is CastVideoOptimizationException) { rethrow; } throw CastVideoOptimizationException( 'Conversione video Cast fallita: $error', ); } } Future _deleteTemporaryFile({ required int mediaId, required File temporaryFile, }) async { try { if (!await temporaryFile.exists()) { return; } await temporaryFile.delete(); _log( 'PARTIAL DELETE ' 'mediaId=$mediaId ' 'path=${temporaryFile.path}', ); } catch (error, stackTrace) { _log( 'PARTIAL DELETE ERROR ' 'mediaId=$mediaId ' 'path=${temporaryFile.path} ' 'error=$error', ); debugPrint( '[CAST VIDEO OPT] PARTIAL DELETE STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); } } /// Elimina la variante definitiva e l'eventuale conversione parziale. /// /// Non può interrompere una conversione nativa già avviata. Il supporto /// alla cancellazione verrà eventualmente aggiunto nel canale Android. Future invalidate(int mediaId) async { final pending = isPending(mediaId); await castVideoCache.invalidate(mediaId); _log( 'INVALIDATE ' 'mediaId=$mediaId ' 'conversionPending=$pending', ); } /// Pulisce all'avvio i file scaduti e le conversioni parziali. Future cleanup() async { _log( 'CLEANUP START ' 'pendingCount=${_pendingConversions.length}', ); await castVideoCache.cleanupExpired(); _log( 'CLEANUP END ' 'pendingCount=${_pendingConversions.length}', ); } } final castVideoOptimizer = CastVideoOptimizer.instance;