479 lines
11 KiB
Dart
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;
|