488 lines
12 KiB
Dart
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;
|