Aves_mio0.88/lib/services/cast/cast_video_optimizer.dart
2026-10-03 12:51:15 +02:00

488 lines
12 KiB
Dart

// 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<int, Future<File>> _pendingConversions = <int, Future<File>>{};
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<File> 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<File> 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<File>();
final conversion = conversionCompleter.future;
_pendingConversions[mediaId] = conversion;
Future<void> 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<File> _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<String, dynamic>(
'convertToMp4',
<String, dynamic>{
'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<void> _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<void> 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<void> cleanup() async {
_log(
'CLEANUP START '
'pendingCount=${_pendingConversions.length}',
);
await castVideoCache.cleanupExpired();
_log(
'CLEANUP END '
'pendingCount=${_pendingConversions.length}',
);
}
}
final castVideoOptimizer = CastVideoOptimizer.instance;