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

479 lines
11 KiB
Dart

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<int, Future<File>> _pendingConversions = <int, Future<File>>{};
final Map<int, Completer<void>> _readySignals = <int, Completer<void>>{};
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<File> prepareFragmentedMp4(int mediaId) async {
final pending = _pendingConversions[mediaId];
if (pending != null) {
_log(
'REUSE PENDING '
'mediaId=$mediaId '
'pendingCount=${_pendingConversions.length}',
);
return pending;
}
final readyCompleter = Completer<void>();
_readySignals[mediaId] = readyCompleter;
final completer = Completer<File>();
final conversion = completer.future;
_pendingConversions[mediaId] = conversion;
Future<void> 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<String> 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<File> _convertFragmentedMp4(
int mediaId,
Completer<void> 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<String, dynamic>(
'convertToMp4',
<String, dynamic>{
'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<File?> 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<void> reset(int mediaId) async {
final pending = isPending(mediaId);
await castHlsCache.resetSession(mediaId);
_log(
'RESET '
'mediaId=$mediaId '
'conversionPending=$pending',
);
}
}
final castHlsOptimizer = CastHlsOptimizer.instance;