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

1991 lines
60 KiB
Dart

// lib/widgets/cast/google_cast_controller.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/remote_identity.dart';
import 'package:aves/remote/remote_origin.dart';
import 'package:aves/remote/remote_settings.dart';
import 'package:aves/services/cast/cast_server.dart';
import 'package:aves/widgets/cast/cast_controller.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:aves/services/cast/cast_hls_optimizer.dart';
import 'package:aves/services/cast/cast_hls_cache.dart';
import 'package:aves/services/cast/cast_video_cache.dart';
import 'package:aves/services/cast/cast_fmp4_first_ts_segment_test.dart';
import 'package:aves/services/cast/cast_progressive_ts_optimizer.dart';
// 4k 3840x2160 quality 80
// 2k 1920x1080 quality 85
// con soglia 500kb
Future<Uint8List> optimizeCastImage(Uint8List bytes) async {
final stopwatch = Stopwatch()..start();
// JPEG: FF D8 FF
final isJpeg = bytes.length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
const skipThreshold = 500 * 1024; // 500 KiB
if (isJpeg && bytes.length < skipThreshold) {
stopwatch.stop();
debugPrint(
'[CAST OPT] '
'SKIP JPEG '
'input=${bytes.length}B '
'elapsedMs=${stopwatch.elapsedMilliseconds}',
);
return bytes;
}
final result = await FlutterImageCompress.compressWithList(
bytes,
minWidth: 1920,
minHeight: 1080,
quality: 85,
format: CompressFormat.jpeg,
keepExif: false,
);
stopwatch.stop();
debugPrint(
'[CAST OPT] '
'input=${bytes.length}B '
'output=${result.length}B '
'elapsedMs=${stopwatch.elapsedMilliseconds}',
);
return Uint8List.fromList(result);
}
Future<List<R>> _mapConcurrent<T, R>({
required List<T> values,
required int maxConcurrent,
required Future<R> Function(T value, int index) convert,
}) async {
if (values.isEmpty) return <R>[];
if (maxConcurrent <= 0) {
throw ArgumentError.value(maxConcurrent, 'maxConcurrent');
}
final results = List<R?>.filled(values.length, null);
var nextIndex = 0;
Future<void> worker() async {
while (true) {
final index = nextIndex;
if (index >= values.length) return;
nextIndex++;
results[index] = await convert(values[index], index);
}
}
final workerCount = values.length < maxConcurrent ? values.length : maxConcurrent;
await Future.wait(List.generate(workerCount, (_) => worker()));
return results.cast<R>();
}
class _CachedShare {
final String url;
final DateTime expiresAt;
const _CachedShare({required this.url, required this.expiresAt});
bool get isValid => DateTime.now().isBefore(
expiresAt.subtract(const Duration(seconds: 15)),
);
}
enum _CastQueueItemKind {
startAsset,
media,
endAsset,
}
class _CastQueueEntry {
final _CastQueueItemKind kind;
final AvesEntry? entry;
final String? assetFileName;
const _CastQueueEntry.start() : kind = _CastQueueItemKind.startAsset, entry = null, assetFileName = 'start.jpg';
const _CastQueueEntry.media(AvesEntry this.entry) : kind = _CastQueueItemKind.media, assetFileName = null;
const _CastQueueEntry.end() : kind = _CastQueueItemKind.endAsset, entry = null, assetFileName = 'end.jpg';
bool get isAsset => kind != _CastQueueItemKind.media;
bool get isVideo => kind == _CastQueueItemKind.media && (entry!.durationMillis ?? 0) > 0;
bool get isRemote => kind == _CastQueueItemKind.media && entry!.origin == RemoteOrigin.value;
}
class _CastBufferItem {
final _CastQueueEntry queueEntry;
final int? mediaId;
const _CastBufferItem({
required this.queueEntry,
required this.mediaId,
});
bool get isOptimizable => !queueEntry.isAsset && !queueEntry.isVideo && !queueEntry.isRemote && mediaId != null;
}
class GoogleCastController {
GoogleCastController._() {
_channel.setMethodCallHandler(
_onNativeEvent,
);
_optimizerChannel.setMethodCallHandler(
_onOptimizerNativeEvent,
);
Timer.periodic(const Duration(seconds: 1), (_) {
if (isPlayingNotifier.value) {
final position = positionNotifier.value + const Duration(seconds: 1);
if (position < durationNotifier.value) {
positionNotifier.value = position;
}
}
});
}
static final GoogleCastController instance = GoogleCastController._();
static const MethodChannel _channel = MethodChannel(
'google_cast',
);
static const MethodChannel _optimizerChannel = MethodChannel(
'aves/cast_video_optimizer',
);
static const bool _testTransportStreamPipeline = false;
static const bool _useProgressiveTransportStreamPipeline = true;
final Set<int> _incrementalCastStartedMediaIds = <int>{};
void activateMethodChannelHandler() {
_channel.setMethodCallHandler(
_onNativeEvent,
);
_optimizerChannel.setMethodCallHandler(
_onOptimizerNativeEvent,
);
}
void deactivateMethodChannelHandler() {}
final ValueNotifier<CastIconState> castIconState = ValueNotifier(CastIconState.idle);
final ValueNotifier<bool> isCastingNotifier = ValueNotifier(false);
final ValueNotifier<int> currentIndexNotifier = ValueNotifier(0);
final ValueNotifier<bool> isPlayingNotifier = ValueNotifier(false);
final ValueNotifier<bool> isBufferingNotifier = ValueNotifier(false);
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<double> volumeNotifier = ValueNotifier(1.0);
final ValueNotifier<bool> isMutedNotifier = ValueNotifier(false);
Timer? _slideshowTimer;
bool _slideshowActive = false;
int _slideshowIntervalSeconds = 5;
static const int _maxConcurrentRemoteShares = 3;
static const int _maxRemoteShareAttempts = 3;
final Map<String, _CachedShare> _shareCache = {};
final Map<String, Future<String>> _pendingShares = <String, Future<String>>{};
String? _cachedToken;
DateTime? _tokenExpiry;
Future<String>? _tokenRequest;
List<String>? resolvedUrls;
List<bool>? resolvedIsVideo;
List<Map<String, dynamic>>? _lastQueueItems;
List<_CastQueueEntry>? _castQueueEntries;
bool _currentIsVideo = false;
bool? _lastLoggedIsPlaying;
bool? _lastLoggedIsBuffering;
// Buffer globale della queue: 5 elementi precedenti, quello corrente
// e 4 elementi successivi. Video e contenuti remoti restano nel buffer,
// ma solo le foto locali vengono ridimensionate.
final Map<int, _CastBufferItem> _castBuffer = <int, _CastBufferItem>{};
final List<int> _decodeQueue = <int>[];
final Set<int> _optimizingIds = <int>{};
final Set<int> _tsInspectionTestsStarted = <int>{};
bool _decodeWorkerRunning = false;
int _optimizationGeneration = 0;
int? _lastOptimizationIndex;
void _queueLog(String message) {
debugPrint('[CAST QUEUE] $message');
}
String _queueEntryKind(_CastQueueEntry queueEntry) {
switch (queueEntry.kind) {
case _CastQueueItemKind.startAsset:
return 'start-asset';
case _CastQueueItemKind.endAsset:
return 'end-asset';
case _CastQueueItemKind.media:
if (queueEntry.isRemote) {
return queueEntry.isVideo ? 'remote-video' : 'remote-photo';
}
return queueEntry.isVideo ? 'local-video' : 'local-photo';
}
}
Completer<void> _castReadyCompleter = Completer<void>();
void forceReset() {
stopSlideshow();
_clearQueueAndOptimization();
castIconState.value = CastIconState.idle;
isCastingNotifier.value = false;
_castReadyCompleter = Completer<void>();
_resetPlaybackState();
}
void _clearQueueAndOptimization() {
resolvedUrls = null;
resolvedIsVideo = null;
_lastQueueItems = null;
_castQueueEntries = null;
_currentIsVideo = false;
_tsInspectionTestsStarted.clear();
_startNewOptimizationSession();
}
void _startNewOptimizationSession() {
final oldBufferLength = _castBuffer.length;
final oldQueueLength = _decodeQueue.length;
_optimizationGeneration++;
_castBuffer.clear();
_decodeQueue.clear();
_optimizingIds.clear();
_lastOptimizationIndex = null;
castServer.clearOptimizedImages();
_queueLog(
'SESSION RESET generation=$_optimizationGeneration '
'removedBuffer=$oldBufferLength removedDecodeQueue=$oldQueueLength',
);
}
Future<String> _getToken(RemoteSettings settings) async {
if (_cachedToken != null && _tokenExpiry != null && DateTime.now().isBefore(_tokenExpiry!)) {
return _cachedToken!;
}
final pendingRequest = _tokenRequest;
if (pendingRequest != null) {
_queueLog('REMOTE TOKEN reuse-pending');
return pendingRequest;
}
final request = _requestNewToken(settings);
_tokenRequest = request;
try {
return await request;
} finally {
if (identical(_tokenRequest, request)) {
_tokenRequest = null;
}
}
}
Future<String> _requestNewToken(RemoteSettings settings) async {
_queueLog('REMOTE TOKEN login-start');
final response = await http
.post(
Uri.parse('${settings.baseUrl}/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': settings.email,
'password': settings.password,
}),
)
.timeout(const Duration(seconds: 20));
if (response.statusCode != 200) {
throw Exception(
'Login remoto fallito '
'status=${response.statusCode} body=${response.body}',
);
}
final data = jsonDecode(response.body);
final token = data['token'] as String?;
if (token == null || token.isEmpty) {
throw StateError('Token remoto assente nella risposta di login');
}
_cachedToken = token;
_tokenExpiry = DateTime.now().add(const Duration(minutes: 10));
_queueLog('REMOTE TOKEN login-done');
return token;
}
Future<String> _resolveCastUrl(AvesEntry entry) async {
if (entry.origin != RemoteOrigin.value) {
return castServer.buildUrlForEntry(entry);
}
final remoteId = entry.effectiveRemoteId;
const shareVariant = 'cast';
if (remoteId == null || remoteId.isEmpty) {
throw StateError(
'[GoogleCast] missing remote id for entry id=${entry.id}, '
'uri=${entry.uri}',
);
}
final cacheKey = '$remoteId|$shareVariant';
final cachedShare = _shareCache[cacheKey];
if (cachedShare != null && cachedShare.isValid) {
_queueLog('REMOTE SHARE cache-hit remoteId=$remoteId');
return cachedShare.url;
}
final pendingShare = _pendingShares[cacheKey];
if (pendingShare != null) {
_queueLog('REMOTE SHARE reuse-pending remoteId=$remoteId');
return await pendingShare;
}
final request = _createRemoteShare(
remoteId: remoteId,
shareVariant: shareVariant,
cacheKey: cacheKey,
);
_pendingShares[cacheKey] = request;
try {
return await request;
} finally {
if (identical(_pendingShares[cacheKey], request)) {
unawaited(_pendingShares.remove(cacheKey));
}
}
}
Future<String> _createRemoteShare({
required String remoteId,
required String shareVariant,
required String cacheKey,
}) async {
final settings = await RemoteSettings.load();
final token = await _getToken(settings);
_queueLog('REMOTE SHARE start remoteId=$remoteId variant=$shareVariant');
var response = await _postShareWithRetry(
settings: settings,
token: token,
remoteId: remoteId,
shareVariant: shareVariant,
);
// Se il token e scaduto prima dei 10 minuti stimati, lo rinnova una volta.
if (response.statusCode == 401) {
_cachedToken = null;
_tokenExpiry = null;
final refreshedToken = await _getToken(settings);
_queueLog('REMOTE SHARE token-refreshed remoteId=$remoteId');
response = await _postShareWithRetry(
settings: settings,
token: refreshedToken,
remoteId: remoteId,
shareVariant: shareVariant,
);
}
if (response.statusCode != 200) {
throw Exception(
'Creazione share remoto fallita remoteId=$remoteId '
'status=${response.statusCode} body=${response.body}',
);
}
final data = jsonDecode(response.body);
final url = data['url'] as String?;
if (url == null || url.isEmpty) {
throw StateError('URL share remoto assente remoteId=$remoteId');
}
final expiresAtMs = data['expiresAt'];
final expiresAt = expiresAtMs is int ? DateTime.fromMillisecondsSinceEpoch(expiresAtMs) : DateTime.now().add(const Duration(minutes: 5));
_shareCache[cacheKey] = _CachedShare(url: url, expiresAt: expiresAt);
_queueLog('REMOTE SHARE done remoteId=$remoteId expiresAt=$expiresAt');
return url;
}
Future<http.Response> _postShareWithRetry({
required RemoteSettings settings,
required String token,
required String remoteId,
required String shareVariant,
}) async {
Object? lastError;
for (var attempt = 1; attempt <= _maxRemoteShareAttempts; attempt++) {
try {
final response = await http
.post(
Uri.parse('${settings.baseUrl}/share/create'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'entryId': remoteId,
'variant': shareVariant,
'ttlSeconds': 300,
'oneTime': false,
}),
)
.timeout(const Duration(seconds: 20));
final retryable = response.statusCode == 429 || response.statusCode == 502 || response.statusCode == 503 || response.statusCode == 504;
if (!retryable || attempt == _maxRemoteShareAttempts) {
return response;
}
final delay = Duration(milliseconds: 500 * attempt);
_queueLog(
'REMOTE SHARE retry remoteId=$remoteId attempt=$attempt '
'status=${response.statusCode} delayMs=${delay.inMilliseconds}',
);
await Future<void>.delayed(delay);
} on TimeoutException catch (error) {
lastError = error;
if (attempt == _maxRemoteShareAttempts) rethrow;
final delay = Duration(milliseconds: 500 * attempt);
_queueLog(
'REMOTE SHARE retry remoteId=$remoteId attempt=$attempt '
'reason=timeout delayMs=${delay.inMilliseconds}',
);
await Future<void>.delayed(delay);
}
}
throw StateError(
'Retry share remoto terminato remoteId=$remoteId error=$lastError',
);
}
Future<void> _onOptimizerNativeEvent(
MethodCall call,
) async {
_queueLog(
'OPTIMIZER NATIVE METHOD RECEIVED '
'method=${call.method} '
'argumentsType=${call.arguments.runtimeType}',
);
switch (call.method) {
case 'tsHlsIncrementalProgress':
_handleTsHlsIncrementalProgress(
call.arguments,
);
break;
default:
_queueLog(
'OPTIMIZER NATIVE EVENT IGNORED '
'method=${call.method}',
);
break;
}
}
Future<void> _onNativeEvent(MethodCall call) async {
switch (call.method) {
case 'onCastReady':
castIconState.value = CastIconState.connected;
isCastingNotifier.value = true;
if (!_castReadyCompleter.isCompleted) {
_castReadyCompleter.complete();
}
break;
case 'onCastStopped':
castIconState.value = CastIconState.idle;
isCastingNotifier.value = false;
_clearQueueAndOptimization();
_incrementalCastStartedMediaIds.clear();
_castReadyCompleter = Completer<void>();
stopSlideshow();
_resetPlaybackState();
break;
case 'onQueueIndexChanged':
final arguments = (call.arguments as Map?) ?? {};
final index = (arguments['index'] as num?)?.toInt() ?? 0;
final isVideo = arguments['isVideo'] == true;
currentIndexNotifier.value = index;
_currentIsVideo = isVideo;
final castEntries = _castQueueEntries;
final isEndAsset = castEntries != null && index >= 0 && index < castEntries.length && castEntries[index].kind == _CastQueueItemKind.endAsset;
if (_slideshowActive && !isVideo && !isEndAsset) {
_scheduleNextPhoto();
} else if (_slideshowActive) {
_slideshowTimer?.cancel();
}
final previousIndex = _lastOptimizationIndex;
final direction = previousIndex != null && index < previousIndex ? -1 : 1;
_logMediaSelectedForDisplay(index, source: 'onQueueIndexChanged');
_prepareOptimizationWindow(index, direction: direction);
_resetServingChoiceForIndex(index);
break;
case 'onVideoFinished':
if (_slideshowActive) await nextManual();
break;
case 'onMediaStatus':
final data = (call.arguments as Map?) ?? {};
final isPlaying = data['isPlaying'] == true;
final isBuffering = data['isBuffering'] == true;
final positionMs = (data['position'] as num?)?.toInt() ?? 0;
final durationMs = (data['duration'] as num?)?.toInt() ?? 0;
isPlayingNotifier.value = isPlaying;
isBufferingNotifier.value = isBuffering;
positionNotifier.value = Duration(milliseconds: positionMs);
durationNotifier.value = Duration(milliseconds: durationMs);
/*
* Registra soltanto i cambiamenti dello stato di riproduzione
* o buffering. Gli aggiornamenti della sola posizione non vengono
* stampati, così il log rimane leggibile.
*/
if (_lastLoggedIsPlaying != isPlaying || _lastLoggedIsBuffering != isBuffering) {
_queueLog(
'MEDIA STATUS '
'index=${currentIndexNotifier.value} '
'isVideo=$_currentIsVideo '
'isPlaying=$isPlaying '
'isBuffering=$isBuffering '
'positionMs=$positionMs '
'durationMs=$durationMs',
);
_lastLoggedIsPlaying = isPlaying;
_lastLoggedIsBuffering = isBuffering;
}
break;
case 'onVolumeChanged':
final data = (call.arguments as Map?) ?? {};
volumeNotifier.value = ((data['volume'] as num?)?.toDouble() ?? 1.0).clamp(0.0, 1.0).toDouble();
isMutedNotifier.value = data['isMuted'] == true;
break;
}
}
void _handleTsHlsIncrementalProgress(
dynamic arguments,
) {
if (arguments is! Map) {
_queueLog(
'TS HLS INCREMENTAL EVENT '
'result=ignored '
'reason=invalidArguments '
'argumentsType=${arguments.runtimeType}',
);
return;
}
final progress = Map<String, dynamic>.from(
arguments,
);
final mediaId = progress['mediaId'];
final event = progress['event'];
final segmentIndex = progress['segmentIndex'];
final publishedSegments = progress['publishedSegmentCount'];
final readySegmentCount = progress['readySegmentCount'];
final ready = progress['ready'];
final playlistExists = progress['playlistExists'];
final playlistBytes = progress['playlistBytes'];
final playlistPath = progress['playlistPath'];
final outputDirectory = progress['outputDirectory'];
_queueLog(
'TS HLS INCREMENTAL EVENT '
'mediaId=$mediaId '
'event=$event '
'segmentIndex=$segmentIndex '
'publishedSegments=$publishedSegments '
'readySegmentCount=$readySegmentCount '
'ready=$ready '
'playlistExists=$playlistExists '
'playlistBytes=$playlistBytes '
'playlistPath=$playlistPath '
'outputDirectory=$outputDirectory',
);
if (event == 'ready' && ready == true) {
_queueLog(
'TS HLS INCREMENTAL READY RECEIVED '
'mediaId=$mediaId '
'publishedSegments=$publishedSegments '
'playlistBytes=$playlistBytes '
'playlistPath=$playlistPath',
);
final normalizedMediaId = switch (mediaId) {
final num value => value.toInt(),
_ => null,
};
if (normalizedMediaId == null) {
_queueLog(
'TS HLS INCREMENTAL READY IGNORED '
'reason=invalidMediaId '
'mediaId=$mediaId',
);
return;
}
if (!_incrementalCastStartedMediaIds.add(normalizedMediaId)) {
_queueLog(
'TS HLS INCREMENTAL READY IGNORED '
'reason=alreadyStarted '
'mediaId=$normalizedMediaId',
);
return;
}
_queueLog(
'TS HLS INCREMENTAL CAST TRIGGER '
'mediaId=$normalizedMediaId '
'publishedSegments=$publishedSegments '
'playlistPath=$playlistPath',
);
}
}
void _resetPlaybackState() {
isPlayingNotifier.value = false;
isBufferingNotifier.value = false;
positionNotifier.value = Duration.zero;
durationNotifier.value = Duration.zero;
_lastLoggedIsPlaying = null;
_lastLoggedIsBuffering = null;
}
int? _mediaIdOf(AvesEntry entry) {
final uri = entry.uri;
final segments = Uri.tryParse(uri)?.pathSegments;
if (segments != null && segments.isNotEmpty) {
final parsed = int.tryParse(segments.last);
if (parsed != null) return parsed;
}
return int.tryParse('${entry.id}');
}
void _logMediaSelectedForDisplay(
int index, {
required String source,
}) {
final entries = _castQueueEntries;
final items = _lastQueueItems;
if (entries == null || index < 0 || index >= entries.length) {
_queueLog('DISPLAY source=$source index=$index unavailable');
return;
}
final queueEntry = entries[index];
final mediaId = queueEntry.entry == null ? null : _mediaIdOf(queueEntry.entry!);
final optimizedReady = mediaId != null && castServer.hasOptimizedImage(mediaId);
final url = items != null && index < items.length ? items[index]['url'] : null;
_queueLog(
'DISPLAY source=$source index=$index mediaId=${mediaId ?? '-'} '
'type=${_queueEntryKind(queueEntry)} optimizedReady=$optimizedReady '
'url=${url ?? '-'}',
);
}
void _resetServingChoiceForIndex(int index) {
final entries = _castQueueEntries;
if (entries == null || index < 0 || index >= entries.length) return;
final entry = entries[index].entry;
if (entry == null) return;
final mediaId = _mediaIdOf(entry);
if (mediaId != null) castServer.resetServingChoice(mediaId);
}
String _bufferIdentity(_CastBufferItem item) {
final queueEntry = item.queueEntry;
if (queueEntry.kind == _CastQueueItemKind.startAsset) return 'asset:start';
if (queueEntry.kind == _CastQueueItemKind.endAsset) return 'asset:end';
return 'media:${queueEntry.entry!.id}';
}
void _prepareOptimizationWindow(
int currentIndex, {
required int direction,
}) {
final entries = _castQueueEntries;
if (entries == null || entries.isEmpty) return;
final safeIndex = currentIndex.clamp(0, entries.length - 1).toInt();
final oldBuffer = Map<int, _CastBufferItem>.from(_castBuffer);
final oldQueueKeys = oldBuffer.values.map(_bufferIdentity).toSet();
_lastOptimizationIndex = safeIndex;
final newBuffer = <int, _CastBufferItem>{};
// Nessun ritorno circolare: agli estremi della queue gli slot mancanti
// restano semplicemente vuoti.
for (var slot = -5; slot <= 4; slot++) {
final queueIndex = safeIndex + slot;
if (queueIndex < 0 || queueIndex >= entries.length) continue;
final queueEntry = entries[queueIndex];
final mediaEntry = queueEntry.entry;
newBuffer[slot] = _CastBufferItem(
queueEntry: queueEntry,
mediaId: mediaEntry == null ? null : _mediaIdOf(mediaEntry),
);
}
final newQueueKeys = newBuffer.values.map(_bufferIdentity).toSet();
for (final oldEntry in oldBuffer.entries) {
if (!newQueueKeys.contains(_bufferIdentity(oldEntry.value))) {
_queueLog(
'BUFFER OUT slot=${oldEntry.key} mediaId=${oldEntry.value.mediaId ?? '-'} '
'type=${_queueEntryKind(oldEntry.value.queueEntry)}',
);
}
}
for (final newEntry in newBuffer.entries) {
if (!oldQueueKeys.contains(_bufferIdentity(newEntry.value))) {
_queueLog(
'BUFFER IN slot=${newEntry.key} mediaId=${newEntry.value.mediaId ?? '-'} '
'type=${_queueEntryKind(newEntry.value.queueEntry)}',
);
}
}
_castBuffer
..clear()
..addAll(newBuffer);
_queueLog(
'BUFFER READY currentIndex=$safeIndex direction=${direction < 0 ? 'backward' : 'forward'} '
'length=${_castBuffer.length} slots=${_castBuffer.keys.toList()..sort()}',
);
final retainedIds = _castBuffer.values.where((item) => item.isOptimizable).map((item) => item.mediaId!).toSet();
// Conserva in memoria solo le foto locali ancora comprese nella finestra
// globale -5 ... +4.
castServer.retainOptimizedImages(retainedIds);
_rebuildDecodeQueue(direction);
}
void _rebuildDecodeQueue(int direction) {
// Quando si procede in avanti si prepara la corrente e poi le quattro
// posizioni successive. Andando indietro si prepara la corrente e poi le
// cinque posizioni precedenti.
final prioritySlots = direction < 0 ? const <int>[0, -1, -2, -3, -4, -5] : const <int>[0, 1, 2, 3, 4];
final oldDecodeQueue = List<int>.from(_decodeQueue);
final queuedIds = <int>{};
_decodeQueue.clear();
for (final slot in prioritySlots) {
final item = _castBuffer[slot];
if (item == null || !item.isOptimizable) continue;
final mediaId = item.mediaId!;
if (castServer.hasOptimizedImage(mediaId)) continue;
if (_optimizingIds.contains(mediaId)) continue;
if (!queuedIds.add(mediaId)) continue;
_decodeQueue.add(mediaId);
_queueLog(
'DECODE ENQUEUE slot=$slot mediaId=$mediaId '
'queueLength=${_decodeQueue.length}',
);
}
for (final mediaId in oldDecodeQueue) {
if (!_decodeQueue.contains(mediaId)) {
_queueLog(
'DECODE DEQUEUE reason=rebuild mediaId=$mediaId '
'queueLength=${_decodeQueue.length}',
);
}
}
_queueLog(
'DECODE QUEUE READY direction=${direction < 0 ? 'backward' : 'forward'} '
'length=${_decodeQueue.length} mediaIds=$_decodeQueue',
);
unawaited(_runDecodeWorker());
}
bool _isMediaStillInBuffer(int mediaId) {
return _castBuffer.values.any(
(item) => item.isOptimizable && item.mediaId == mediaId,
);
}
Future<void> _runDecodeWorker() async {
if (_decodeWorkerRunning) {
_queueLog('DECODE WORKER already-running queueLength=${_decodeQueue.length}');
return;
}
_decodeWorkerRunning = true;
_queueLog('DECODE WORKER START queueLength=${_decodeQueue.length}');
try {
while (_decodeQueue.isNotEmpty) {
final mediaId = _decodeQueue.removeAt(0);
_queueLog(
'DECODE DEQUEUE reason=processing mediaId=$mediaId '
'queueLength=${_decodeQueue.length}',
);
if (!_isMediaStillInBuffer(mediaId)) {
_queueLog('DECODE SKIP mediaId=$mediaId reason=outside-buffer');
continue;
}
if (castServer.hasOptimizedImage(mediaId)) {
_queueLog('DECODE SKIP mediaId=$mediaId reason=already-ready');
continue;
}
if (!_optimizingIds.add(mediaId)) {
_queueLog('DECODE SKIP mediaId=$mediaId reason=already-processing');
continue;
}
final generation = _optimizationGeneration;
final decodeStopwatch = Stopwatch()..start();
_queueLog(
'DECODE START mediaId=$mediaId generation=$generation '
'remainingQueue=${_decodeQueue.length}',
);
try {
final readStopwatch = Stopwatch()..start();
final originalBytes = await castServer.readOriginalBytes(mediaId);
readStopwatch.stop();
if (originalBytes == null || originalBytes.isEmpty) continue;
_queueLog(
'DECODE INPUT mediaId=$mediaId '
'bytes=${originalBytes.length} '
'readMs=${readStopwatch.elapsedMilliseconds}',
);
final optimizeStopwatch = Stopwatch()..start();
final optimized = await optimizeCastImage(originalBytes);
optimizeStopwatch.stop();
_queueLog(
'DECODE OPTIMIZE mediaId=$mediaId '
'inputBytes=${originalBytes.length} '
'outputBytes=${optimized.length} '
'optimizeMs=${optimizeStopwatch.elapsedMilliseconds}',
);
if (generation != _optimizationGeneration || !_isMediaStillInBuffer(mediaId)) {
_queueLog(
'DECODE END mediaId=$mediaId result=discarded '
'reason=${generation != _optimizationGeneration ? 'old-generation' : 'outside-buffer'}',
);
continue;
}
castServer.storeOptimizedImage(mediaId, optimized);
_queueLog(
'DECODE END mediaId=$mediaId result=stored '
'inputBytes=${originalBytes.length} '
'outputBytes=${optimized.length} '
'readMs=${readStopwatch.elapsedMilliseconds} '
'optimizeMs=${optimizeStopwatch.elapsedMilliseconds}',
);
} catch (error, stackTrace) {
// Se la riduzione fallisce, il server continua a servire l'originale.
_queueLog(
'DECODE END mediaId=$mediaId result=error '
'elapsedMs=${decodeStopwatch.elapsedMilliseconds} error=$error',
);
debugPrint('[CAST QUEUE] DECODE STACK mediaId=$mediaId\n$stackTrace');
} finally {
_optimizingIds.remove(mediaId);
}
}
} finally {
_decodeWorkerRunning = false;
_queueLog('DECODE WORKER STOP queueLength=${_decodeQueue.length}');
// Un cambio indice puo avere ricostruito la coda mentre il worker stava
// terminando. In quel caso riavvia il worker sulla nuova priorita.
if (_decodeQueue.isNotEmpty) {
unawaited(_runDecodeWorker());
}
}
}
Future<void> _waitReady() async {
if (_castReadyCompleter.isCompleted) return;
await _castReadyCompleter.future;
}
Future<void> _startFmp4FirstTsSegmentTest(
int mediaId,
) async {
try {
final inputPath = await castHlsCache.getWorkingMp4Path(
mediaId,
);
final directory = await castHlsCache.getMediaDirectory(
mediaId,
);
final outputPath = '${directory.path}/segment_00000.ts';
_queueLog(
'FMP4 FIRST TS TOOL START '
'mediaId=$mediaId '
'inputPath=$inputPath '
'outputPath=$outputPath',
);
final report = await CastFmp4FirstTsSegmentTest.run(
inputPath: inputPath,
outputPath: outputPath,
);
_queueLog(
'FMP4 FIRST TS TOOL END '
'mediaId=$mediaId '
'videoSamples=${report['videoSamples']} '
'audioSamples=${report['audioSamples']} '
'keyframes=${report['keyframes']} '
'aud=${report['accessUnitDelimiterCount']} '
'outputBytes=${report['outputBytes']} '
'outputPath=${report['outputPath']}',
);
} catch (error, stackTrace) {
_queueLog(
'FMP4 FIRST TS TOOL ERROR '
'mediaId=$mediaId '
'error=$error',
);
debugPrint(
'[CAST QUEUE] FMP4 FIRST TS TOOL STACK '
'mediaId=$mediaId\n'
'$stackTrace',
);
}
}
void _startTsInspectionTest(AvesEntry entry) {
final mediaId = _mediaIdOf(entry);
if (mediaId == null) {
_queueLog(
'TS INSPECT SKIP '
'reason=missing-media-id '
'entryId=${entry.id}',
);
return;
}
if (!_tsInspectionTestsStarted.add(mediaId)) {
_queueLog(
'TS INSPECT SKIP '
'mediaId=$mediaId '
'reason=already-started',
);
return;
}
Future<void> runInspection() async {
try {
final mp4File = await castVideoCache.getValidFile(
mediaId,
);
if (mp4File == null) {
_queueLog(
'TS INSPECT SKIP '
'mediaId=$mediaId '
'reason=mp4-cache-miss',
);
return;
}
final inputBytes = await mp4File.length();
if (inputBytes <= 0) {
_queueLog(
'TS INSPECT SKIP '
'mediaId=$mediaId '
'reason=mp4-cache-empty '
'path=${mp4File.path}',
);
return;
}
_queueLog(
'TS INSPECT START '
'mediaId=$mediaId '
'inputBytes=$inputBytes '
'inputPath=${mp4File.path}',
);
final stopwatch = Stopwatch()..start();
final report = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'inspectMp4ForHlsTs',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': mp4File.path,
'maximumDurationMs': 15000,
},
);
stopwatch.stop();
if (report == null) {
throw StateError(
'Report TS Inspector assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS INSPECT END '
'mediaId=$mediaId '
'result=success '
'elapsedMs=${stopwatch.elapsedMilliseconds} '
'nativeElapsedMs=${report['elapsedMs']} '
'trackCount=${report['trackCount']} '
'videoTrackIndex=${report['videoTrackIndex']} '
'videoMime=${report['videoMime']} '
'videoDurationUs=${report['videoDurationUs']} '
'size=${report['width']}x${report['height']} '
'frameRate=${report['frameRate']} '
'videoBitrate=${report['videoBitrate']} '
'videoCsd0Bytes=${report['videoCsd0Bytes']} '
'videoCsd1Bytes=${report['videoCsd1Bytes']} '
'audioTrackIndex=${report['audioTrackIndex']} '
'audioMime=${report['audioMime']} '
'audioDurationUs=${report['audioDurationUs']} '
'sampleRate=${report['sampleRate']} '
'channelCount=${report['channelCount']} '
'audioBitrate=${report['audioBitrate']} '
'audioCsd0Bytes=${report['audioCsd0Bytes']} '
'videoSamples=${report['videoSampleCount']} '
'audioSamples=${report['audioSampleCount']} '
'keyframes=${report['videoKeyframeCount']} '
'keyframeTimesUs=${report['keyframeTimesUs']} '
'inspectedDurationUs=${report['inspectedDurationUs']}',
);
_queueLog(
'TS PSI TEST START '
'mediaId=$mediaId',
);
final psiReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsPsiTest',
<String, dynamic>{
'mediaId': mediaId,
},
);
if (psiReport == null) {
throw StateError(
'Report TS PSI Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS PSI TEST END '
'mediaId=$mediaId '
'result=success '
'success=${psiReport['success']} '
'outputBytes=${psiReport['outputBytes']} '
'packetCount=${psiReport['packetCount']} '
'packetSize=${psiReport['packetSize']} '
'patPid=${psiReport['patPid']} '
'pmtPid=${psiReport['pmtPid']} '
'videoPid=${psiReport['videoPid']} '
'audioPid=${psiReport['audioPid']} '
'pcrPid=${psiReport['pcrPid']} '
'elapsedMs=${psiReport['elapsedMs']} '
'outputPath=${psiReport['outputPath']}',
);
_queueLog(
'TS ANNEX B TEST START '
'mediaId=$mediaId',
);
final annexBReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsAnnexBTest',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': mp4File.path,
},
);
if (annexBReport == null) {
throw StateError(
'Report TS Annex B Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS ANNEX B TEST END '
'mediaId=$mediaId '
'result=success '
'inputBytes=${annexBReport['inputBytes']} '
'outputBytes=${annexBReport['outputBytes']} '
'sampleTimeUs=${annexBReport['sampleTimeUs']} '
'sampleFlags=${annexBReport['sampleFlags']} '
'csd0Bytes=${annexBReport['csd0Bytes']} '
'csd1Bytes=${annexBReport['csd1Bytes']} '
'nalUnitCount=${annexBReport['nalUnitCount']} '
'nalUnitTypes=${annexBReport['nalUnitTypes']} '
'sourceWasAnnexB=${annexBReport['sourceWasAnnexB']} '
'elapsedMs=${annexBReport['elapsedMs']} '
'outputPath=${annexBReport['outputPath']}',
);
_queueLog(
'TS VIDEO SAMPLE TEST START '
'mediaId=$mediaId',
);
final videoSampleReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsVideoSampleTest',
<String, dynamic>{
'mediaId': mediaId,
},
);
if (videoSampleReport == null) {
throw StateError(
'Report TS Video Sample Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS VIDEO SAMPLE TEST END '
'mediaId=$mediaId '
'result=success '
'annexBBytes=${videoSampleReport['annexBBytes']} '
'outputBytes=${videoSampleReport['outputBytes']} '
'packetSize=${videoSampleReport['packetSize']} '
'psiPacketCount=${videoSampleReport['psiPacketCount']} '
'videoPacketCount=${videoSampleReport['videoPacketCount']} '
'totalPacketCount=${videoSampleReport['totalPacketCount']} '
'videoPid=${videoSampleReport['videoPid']} '
'firstVideoContinuityCounter='
'${videoSampleReport['firstVideoContinuityCounter']} '
'nextVideoContinuityCounter='
'${videoSampleReport['nextVideoContinuityCounter']} '
'pts90Khz=${videoSampleReport['pts90Khz']} '
'dts90Khz=${videoSampleReport['dts90Khz']} '
'pcr90Khz=${videoSampleReport['pcr90Khz']} '
'elapsedMs=${videoSampleReport['elapsedMs']} '
'outputPath=${videoSampleReport['outputPath']}',
);
_queueLog(
'TS VIDEO SEGMENT TEST START '
'mediaId=$mediaId',
);
final videoSegmentReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsVideoSegmentTest',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': mp4File.path,
'segmentDurationMs': 3000,
},
);
if (videoSegmentReport == null) {
throw StateError(
'Report TS Video Segment Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS VIDEO SEGMENT TEST END '
'mediaId=$mediaId '
'result=success '
'videoSamples=${videoSegmentReport['videoSampleCount']} '
'keyframes=${videoSegmentReport['keyframeCount']} '
'videoPackets=${videoSegmentReport['videoPacketCount']} '
'psiPackets=${videoSegmentReport['psiPacketCount']} '
'totalPackets=${videoSegmentReport['totalPacketCount']} '
'outputBytes=${videoSegmentReport['outputBytes']} '
'packetSize=${videoSegmentReport['packetSize']} '
'videoPid=${videoSegmentReport['videoPid']} '
'durationUs=${videoSegmentReport['durationUs']} '
'firstPts90Khz=${videoSegmentReport['firstPts90Khz']} '
'lastPts90Khz=${videoSegmentReport['lastPts90Khz']} '
'nextVideoContinuityCounter='
'${videoSegmentReport['nextVideoContinuityCounter']} '
'elapsedMs=${videoSegmentReport['elapsedMs']} '
'outputPath=${videoSegmentReport['outputPath']}',
);
_queueLog(
'TS AUDIO SAMPLE TEST START '
'mediaId=$mediaId',
);
final audioSampleReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsAudioSampleTest',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': mp4File.path,
},
);
if (audioSampleReport == null) {
throw StateError(
'Report TS Audio Sample Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS AUDIO SAMPLE TEST END '
'mediaId=$mediaId '
'result=success '
'rawAacBytes=${audioSampleReport['rawAacBytes']} '
'adtsBytes=${audioSampleReport['adtsBytes']} '
'audioSpecificConfigBytes='
'${audioSampleReport['audioSpecificConfigBytes']} '
'audioObjectType=${audioSampleReport['audioObjectType']} '
'sampleRateIndex=${audioSampleReport['sampleRateIndex']} '
'sampleRate=${audioSampleReport['sampleRate']} '
'channelCount=${audioSampleReport['channelCount']} '
'sampleTimeUs=${audioSampleReport['sampleTimeUs']} '
'pts90Khz=${audioSampleReport['pts90Khz']} '
'audioPackets=${audioSampleReport['audioPacketCount']} '
'psiPackets=${audioSampleReport['psiPacketCount']} '
'totalPackets=${audioSampleReport['totalPacketCount']} '
'audioPid=${audioSampleReport['audioPid']} '
'firstAudioContinuityCounter='
'${audioSampleReport['firstAudioContinuityCounter']} '
'nextAudioContinuityCounter='
'${audioSampleReport['nextAudioContinuityCounter']} '
'outputBytes=${audioSampleReport['outputBytes']} '
'elapsedMs=${audioSampleReport['elapsedMs']} '
'outputPath=${audioSampleReport['outputPath']}',
);
_queueLog(
'TS AV SEGMENT TEST START '
'mediaId=$mediaId',
);
final avSegmentReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsAvSegmentTest',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': mp4File.path,
'segmentDurationMs': 3000,
},
);
if (avSegmentReport == null) {
throw StateError(
'Report TS A/V Segment Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS AV SEGMENT TEST END '
'mediaId=$mediaId '
'result=success '
'videoSamples=${avSegmentReport['videoSampleCount']} '
'audioSamples=${avSegmentReport['audioSampleCount']} '
'keyframes=${avSegmentReport['keyframeCount']} '
'videoPackets=${avSegmentReport['videoPacketCount']} '
'audioPackets=${avSegmentReport['audioPacketCount']} '
'psiPackets=${avSegmentReport['psiPacketCount']} '
'totalPackets=${avSegmentReport['totalPacketCount']} '
'outputBytes=${avSegmentReport['outputBytes']} '
'packetSize=${avSegmentReport['packetSize']} '
'timelineBaseUs=${avSegmentReport['timelineBaseUs']} '
'videoCutTimeUs=${avSegmentReport['videoCutTimeUs']} '
'firstVideoPts90Khz=${avSegmentReport['firstVideoPts90Khz']} '
'lastVideoPts90Khz=${avSegmentReport['lastVideoPts90Khz']} '
'firstAudioPts90Khz=${avSegmentReport['firstAudioPts90Khz']} '
'lastAudioPts90Khz=${avSegmentReport['lastAudioPts90Khz']} '
'nextVideoContinuityCounter='
'${avSegmentReport['nextVideoContinuityCounter']} '
'nextAudioContinuityCounter='
'${avSegmentReport['nextAudioContinuityCounter']} '
'sampleRate=${avSegmentReport['sampleRate']} '
'channelCount=${avSegmentReport['channelCount']} '
'elapsedMs=${avSegmentReport['elapsedMs']} '
'outputPath=${avSegmentReport['outputPath']}'
'originalSpsLevels=${avSegmentReport['originalSpsLevels']} '
'targetLevelIdc=${avSegmentReport['targetLevelIdc']} '
'patchedSpsCount=${avSegmentReport['patchedSpsCount']} ',
);
_queueLog(
'TS HLS VOD TEST START '
'mediaId=$mediaId',
);
final hlsVodReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsHlsVodTest',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': mp4File.path,
'segmentCount': 5,
'segmentDurationMs': 3000,
},
);
if (hlsVodReport == null) {
throw StateError(
'Report TS HLS VOD Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS HLS VOD TEST END '
'mediaId=$mediaId '
'result=success '
'segmentCount=${hlsVodReport['segmentCount']} '
'segmentFiles=${hlsVodReport['segmentFileNames']} '
'segmentDurationsUs=${hlsVodReport['segmentDurationsUs']} '
'totalDurationUs=${hlsVodReport['totalDurationUs']} '
'totalOutputBytes=${hlsVodReport['totalOutputBytes']} '
'targetDurationSeconds='
'${hlsVodReport['targetDurationSeconds']} '
'playlistBytes=${hlsVodReport['playlistBytes']} '
'elapsedMs=${hlsVodReport['elapsedMs']} '
'outputDirectory=${hlsVodReport['outputDirectory']} '
'playlistPath=${hlsVodReport['playlistPath']}',
);
_queueLog(
'TS HLS INCREMENTAL TEST START '
'mediaId=$mediaId',
);
await castHlsCache.resetSession(mediaId);
final incrementalHlsDirectory = await castHlsCache.getMediaDirectory(
mediaId,
);
_queueLog(
'TS HLS INCREMENTAL OUTPUT DIRECTORY '
'mediaId=$mediaId '
'path=${incrementalHlsDirectory.path}',
);
final incrementalReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
'writeTsHlsIncrementalTest',
<String, dynamic>{
'mediaId': mediaId,
'inputPath': mp4File.path,
'outputDirectory': incrementalHlsDirectory.path,
'segmentCount': 5,
'segmentDurationMs': 3000,
'readySegmentCount': 3,
'publicationDelayMs': 1000,
},
);
if (incrementalReport == null) {
throw StateError(
'Report TS HLS Incremental Test assente '
'mediaId=$mediaId',
);
}
_queueLog(
'TS HLS INCREMENTAL TEST END '
'mediaId=$mediaId '
'result=success '
'segmentCount=${incrementalReport['segmentCount']} '
'publishedSegments='
'${incrementalReport['publishedSegmentCount']} '
'readySegmentCount='
'${incrementalReport['readySegmentCount']} '
'readyElapsedMs=${incrementalReport['readyElapsedMs']} '
'segmentFiles=${incrementalReport['segmentFileNames']} '
'segmentDurationsUs='
'${incrementalReport['segmentDurationsUs']} '
'totalDurationUs=${incrementalReport['totalDurationUs']} '
'totalOutputBytes=${incrementalReport['totalOutputBytes']} '
'playlistBytes=${incrementalReport['playlistBytes']} '
'endList=${incrementalReport['endList']} '
'elapsedMs=${incrementalReport['elapsedMs']} '
'outputDirectory=${incrementalReport['outputDirectory']} '
'playlistPath=${incrementalReport['playlistPath']}',
);
} catch (error, stackTrace) {
_queueLog(
'TS INSPECT END '
'mediaId=$mediaId '
'result=error '
'error=$error',
);
debugPrint(
'[CAST QUEUE] TS INSPECT STACK '
'mediaId=$mediaId\n'
'$stackTrace',
);
}
}
unawaited(runInspection());
}
Future<List<Map<String, dynamic>>> _buildQueue(
List<AvesEntry> entries,
) async {
await castServer.ensureStarted();
final castEntries = <_CastQueueEntry>[
const _CastQueueEntry.start(),
...entries.map(_CastQueueEntry.media),
const _CastQueueEntry.end(),
];
_castQueueEntries = List<_CastQueueEntry>.unmodifiable(castEntries);
final mediaItems = List<Map<String, dynamic>?>.filled(entries.length, null);
final remoteIndexes = <int>[];
// Gli URL locali sono economici e vengono creati subito. Solo gli share
// remoti vengono messi nel pool a concorrenza limitata.
for (var index = 0; index < entries.length; index++) {
final entry = entries[index];
final isVideo = (entry.durationMillis ?? 0) > 0 || entry.mimeType.startsWith('video/');
if (entry.origin == RemoteOrigin.value) {
remoteIndexes.add(index);
continue;
}
if (isVideo) {
final mediaId = _mediaIdOf(entry);
final hlsReady =
mediaId != null &&
await castHlsCache.isReadyForPlayback(
mediaId,
minimumSegments: 3,
);
if (hlsReady) {
final hlsUrl = castServer.buildHlsUrl(mediaId);
mediaItems[index] = <String, dynamic>{
'url': hlsUrl,
'isVideo': true,
'mimeType': 'application/vnd.apple.mpegurl',
'title': entry.bestTitle ?? '',
};
_queueLog(
'HLS QUEUE ITEM '
'entryIndex=$index '
'mediaId=$mediaId '
'ready=true '
'url=$hlsUrl',
);
continue;
}
}
mediaItems[index] = <String, dynamic>{
'url': castServer.buildUrlForEntry(entry),
'isVideo': isVideo,
'mimeType': entry.mimeType,
'title': entry.bestTitle ?? '',
};
}
_queueLog(
'REMOTE QUEUE READY remote=${remoteIndexes.length} '
'local=${entries.length - remoteIndexes.length} '
'maxConcurrent=$_maxConcurrentRemoteShares',
);
var activeRemote = 0;
var completedRemote = 0;
await _mapConcurrent<int, void>(
values: remoteIndexes,
maxConcurrent: _maxConcurrentRemoteShares,
convert: (entryIndex, _) async {
final entry = entries[entryIndex];
activeRemote++;
_queueLog(
'REMOTE QUEUE START entryIndex=$entryIndex active=$activeRemote '
'completed=$completedRemote total=${remoteIndexes.length}',
);
try {
final url = await _resolveCastUrl(entry);
mediaItems[entryIndex] = <String, dynamic>{
'url': url,
'isVideo': (entry.durationMillis ?? 0) > 0,
'mimeType': entry.mimeType,
'title': entry.bestTitle ?? '',
};
completedRemote++;
} finally {
activeRemote--;
_queueLog(
'REMOTE QUEUE END entryIndex=$entryIndex active=$activeRemote '
'completed=$completedRemote total=${remoteIndexes.length}',
);
}
},
);
final resolvedMediaItems = mediaItems.cast<Map<String, dynamic>>();
final resolved = <Map<String, dynamic>>[
<String, dynamic>{
'url': castServer.buildUrlForAsset('start.jpg'),
'isVideo': false,
'mimeType': 'image/jpeg',
'title': '',
},
...resolvedMediaItems,
<String, dynamic>{
'url': castServer.buildUrlForAsset('end.jpg'),
'isVideo': false,
'mimeType': 'image/jpeg',
'title': '',
},
];
resolvedUrls = resolved.map((item) => item['url'] as String).toList();
resolvedIsVideo = resolved.map((item) => item['isVideo'] as bool).toList();
_queueLog(
'QUEUE BUILT total=${resolved.length} media=${entries.length} '
'remote=${remoteIndexes.length} startAssetIndex=0 '
'endAssetIndex=${resolved.length - 1}',
);
return resolved;
}
Future<void> _showHlsPreparingScreen() async {
await castServer.ensureStarted();
final startUrl = castServer.buildUrlForAsset(
'start.jpg',
);
final preparingItems = <Map<String, dynamic>>[
<String, dynamic>{
'url': startUrl,
'isVideo': false,
'mimeType': 'image/jpeg',
'title': '',
},
];
resolvedUrls = <String>[startUrl];
resolvedIsVideo = <bool>[false];
_castQueueEntries = const <_CastQueueEntry>[
_CastQueueEntry.start(),
];
_lastQueueItems = preparingItems;
currentIndexNotifier.value = 0;
_currentIsVideo = false;
_queueLog(
'HLS PREPARING LOAD '
'items=1 '
'startIndex=0 '
'url=$startUrl',
);
await _channel.invokeMethod(
'loadQueue',
<String, dynamic>{
'items': preparingItems,
'startIndex': 0,
},
);
}
Future<void> startManualQueue({
required List<AvesEntry> entries,
required int startIndex,
}) async {
if (entries.isEmpty) {
return;
}
await _waitReady();
await castServer.ensureStarted();
_startNewOptimizationSession();
final requestGeneration = _optimizationGeneration;
_lastQueueItems = const <Map<String, dynamic>>[];
_castQueueEntries = const <_CastQueueEntry>[];
resolvedUrls = const <String>[];
resolvedIsVideo = const <bool>[];
currentIndexNotifier.value = 0;
_currentIsVideo = false;
_queueLog(
'CAST REQUEST RESET '
'generation=$requestGeneration '
'entries=${entries.length} '
'startIndex=$startIndex',
);
final requestedEntryIndex = startIndex.clamp(
0,
entries.length - 1,
);
final requestedEntry = entries[requestedEntryIndex];
final requestedIsVideo = (requestedEntry.durationMillis ?? 0) > 0 || requestedEntry.mimeType.startsWith('video/');
final requestedIsLocal = requestedEntry.origin != RemoteOrigin.value;
if (requestedIsVideo && requestedIsLocal) {
_startTsInspectionTest(
requestedEntry,
);
}
if (requestedIsVideo && requestedIsLocal) {
final mediaId = _mediaIdOf(requestedEntry);
if (mediaId == null) {
throw StateError(
'Media ID non disponibile per il video selezionato',
);
}
if (_testTransportStreamPipeline) {
_queueLog(
'LEGACY FMP4 PIPELINE SKIPPED '
'entryIndex=$requestedEntryIndex '
'mediaId=$mediaId '
'reason=transportStreamPipelineTest',
);
unawaited(
_startFmp4FirstTsSegmentTest(
mediaId,
),
);
} else {
await _showHlsPreparingScreen();
_queueLog(
'HLS PREPARE START '
'entryIndex=$requestedEntryIndex '
'mediaId=$mediaId '
'mime=${requestedEntry.mimeType}',
);
final stopwatch = Stopwatch()..start();
try {
final String hlsUrl;
if (_useProgressiveTransportStreamPipeline) {
hlsUrl = await castProgressiveTsOptimizer.prepareUntilReady(
mediaId,
);
_queueLog(
'PROGRESSIVE TS READY '
'entryIndex=$requestedEntryIndex '
'mediaId=$mediaId '
'url=$hlsUrl',
);
} else {
hlsUrl = await castHlsOptimizer.prepareUntilReady(
mediaId,
);
}
if (requestGeneration != _optimizationGeneration) {
_queueLog(
'CAST REQUEST STALE '
'stage=hls-ready '
'requestGeneration=$requestGeneration '
'currentGeneration=$_optimizationGeneration '
'entryIndex=$requestedEntryIndex '
'mediaId=$mediaId',
);
return;
}
stopwatch.stop();
_queueLog(
'HLS PREPARE READY '
'entryIndex=$requestedEntryIndex '
'mediaId=$mediaId '
'pipeline='
'${_useProgressiveTransportStreamPipeline ? 'mpeg-ts' : 'fragmented-mp4'} '
'elapsedMs=${stopwatch.elapsedMilliseconds} '
'url=$hlsUrl',
);
} catch (error, stackTrace) {
stopwatch.stop();
_queueLog(
'HLS PREPARE ERROR '
'entryIndex=$requestedEntryIndex '
'mediaId=$mediaId '
'elapsedMs=${stopwatch.elapsedMilliseconds} '
'error=$error',
);
debugPrint(
'[CAST QUEUE] HLS PREPARE STACK '
'mediaId=$mediaId\n'
'$stackTrace',
);
rethrow;
}
}
}
final items = await _buildQueue(entries);
if (requestGeneration != _optimizationGeneration) {
_queueLog(
'CAST REQUEST STALE '
'stage=queue-built '
'requestGeneration=$requestGeneration '
'currentGeneration=$_optimizationGeneration '
'entryIndex=$requestedEntryIndex',
);
return;
}
_lastQueueItems = items;
if (items.isEmpty) {
return;
}
final queueStartIndex = requestedEntryIndex + 1;
currentIndexNotifier.value = queueStartIndex;
_currentIsVideo = items[queueStartIndex]['isVideo'] == true;
_logMediaSelectedForDisplay(
queueStartIndex,
source: 'hlsLoadQueue',
);
_prepareOptimizationWindow(
queueStartIndex,
direction: 1,
);
_resetServingChoiceForIndex(
queueStartIndex,
);
_queueLog(
'CAST LOAD '
'items=${items.length} '
'requestedEntryIndex=$requestedEntryIndex '
'queueStartIndex=$queueStartIndex '
'isVideo=$_currentIsVideo '
'decodeQueueLength=${_decodeQueue.length}',
);
if (requestGeneration != _optimizationGeneration) {
_queueLog(
'CAST REQUEST STALE '
'stage=before-load '
'requestGeneration=$requestGeneration '
'currentGeneration=$_optimizationGeneration '
'entryIndex=$requestedEntryIndex',
);
return;
}
await _channel.invokeMethod(
'loadQueue',
<String, dynamic>{
'items': items,
'startIndex': queueStartIndex,
},
);
}
Future<void> startSlideshow({
required List<AvesEntry> entries,
required int startIndex,
int intervalSeconds = 5,
}) async {
stopSlideshow();
_slideshowActive = true;
_slideshowIntervalSeconds = intervalSeconds;
await _waitReady();
await startManualQueue(entries: entries, startIndex: startIndex);
}
void _scheduleNextPhoto() {
_slideshowTimer?.cancel();
if (_currentIsVideo) return;
_slideshowTimer = Timer(
Duration(seconds: _slideshowIntervalSeconds),
() async {
if (_slideshowActive) await nextManual();
},
);
}
void stopSlideshow() {
_slideshowTimer?.cancel();
_slideshowTimer = null;
_slideshowActive = false;
}
Future<void> playCast() => _channel.invokeMethod('play');
Future<void> pauseCast() => _channel.invokeMethod('pause');
Future<void> seekCast(Duration position) async {
final milliseconds = position.inMilliseconds;
if ((positionNotifier.value.inMilliseconds - milliseconds).abs() < 500) {
return;
}
await _channel.invokeMethod('seekTo', {'position': milliseconds});
positionNotifier.value = position;
}
Future<void> setCastVolume(double value) async {
final clamped = value.clamp(0.0, 1.0).toDouble();
volumeNotifier.value = clamped;
await _channel.invokeMethod('setVolume', {'volume': clamped});
}
Future<void> toggleMuteCast() async {
final mute = !isMutedNotifier.value;
await _channel.invokeMethod('setMute', {'mute': mute});
isMutedNotifier.value = mute;
}
Future<void> nextManual() async {
final count = _lastQueueItems?.length ?? resolvedUrls?.length ?? 0;
if (count <= 0) return;
final current = currentIndexNotifier.value;
if (current >= count - 1) {
_queueLog('NAV NEXT ignored index=$current reason=end-of-queue');
return;
}
await _channel.invokeMethod('next');
}
Future<void> prevManual() async {
final count = _lastQueueItems?.length ?? resolvedUrls?.length ?? 0;
if (count <= 0) return;
final current = currentIndexNotifier.value;
if (current <= 0) {
_queueLog('NAV PREV ignored index=$current reason=start-of-queue');
return;
}
await _channel.invokeMethod('prev');
}
Future<void> stopGoogleCast() async {
stopSlideshow();
_clearQueueAndOptimization();
castIconState.value = CastIconState.idle;
isCastingNotifier.value = false;
_castReadyCompleter = Completer<void>();
_resetPlaybackState();
return _channel.invokeMethod('stopCast');
}
Future<void> showGoogleCastDialog() {
CastController.instance.castIconState.value = CastIconState.connecting;
return _channel.invokeMethod('showCastDialog');
}
Future<List<Map<String, dynamic>>> buildQueue(List<AvesEntry> entries) {
return _buildQueue(entries);
}
MethodChannel get channel => _channel;
}