// 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 'dart:typed_data'; // 4k 3840x2160 quality 80 // 2k 1920x1080 quality 85 // con soglia 500kb Future 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> _mapConcurrent({ required List values, required int maxConcurrent, required Future Function(T value, int index) convert, }) async { if (values.isEmpty) return []; if (maxConcurrent <= 0) { throw ArgumentError.value(maxConcurrent, 'maxConcurrent'); } final results = List.filled(values.length, null); var nextIndex = 0; Future 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(); } 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; final Set _incrementalCastStartedMediaIds = {}; void activateMethodChannelHandler() { _channel.setMethodCallHandler( _onNativeEvent, ); _optimizerChannel.setMethodCallHandler( _onOptimizerNativeEvent, ); } void deactivateMethodChannelHandler() {} final ValueNotifier castIconState = ValueNotifier(CastIconState.idle); final ValueNotifier isCastingNotifier = ValueNotifier(false); final ValueNotifier currentIndexNotifier = ValueNotifier(0); final ValueNotifier isPlayingNotifier = ValueNotifier(false); final ValueNotifier isBufferingNotifier = ValueNotifier(false); final ValueNotifier positionNotifier = ValueNotifier(Duration.zero); final ValueNotifier durationNotifier = ValueNotifier(Duration.zero); final ValueNotifier volumeNotifier = ValueNotifier(1.0); final ValueNotifier isMutedNotifier = ValueNotifier(false); Timer? _slideshowTimer; bool _slideshowActive = false; int _slideshowIntervalSeconds = 5; static const int _maxConcurrentRemoteShares = 3; static const int _maxRemoteShareAttempts = 3; final Map _shareCache = {}; final Map> _pendingShares = >{}; String? _cachedToken; DateTime? _tokenExpiry; Future? _tokenRequest; List? resolvedUrls; List? resolvedIsVideo; List>? _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 _castBuffer = {}; final List _decodeQueue = []; final Set _optimizingIds = {}; final Set _tsInspectionTestsStarted = {}; 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 _castReadyCompleter = Completer(); void forceReset() { stopSlideshow(); _clearQueueAndOptimization(); castIconState.value = CastIconState.idle; isCastingNotifier.value = false; _castReadyCompleter = Completer(); _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 _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 _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 _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 _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 _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.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.delayed(delay); } } throw StateError( 'Retry share remoto terminato remoteId=$remoteId error=$lastError', ); } Future _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 _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(); 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.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.from(_castBuffer); final oldQueueKeys = oldBuffer.values.map(_bufferIdentity).toSet(); _lastOptimizationIndex = safeIndex; final newBuffer = {}; // 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 [0, -1, -2, -3, -4, -5] : const [0, 1, 2, 3, 4]; final oldDecodeQueue = List.from(_decodeQueue); final queuedIds = {}; _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 _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 _waitReady() async { if (_castReadyCompleter.isCompleted) return; await _castReadyCompleter.future; } 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 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( 'inspectMp4ForHlsTs', { '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( 'writeTsPsiTest', { '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( 'writeTsAnnexBTest', { '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( 'writeTsVideoSampleTest', { '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( 'writeTsVideoSegmentTest', { '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( 'writeTsAudioSampleTest', { '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( 'writeTsAvSegmentTest', { '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( 'writeTsHlsVodTest', { '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( 'writeTsHlsIncrementalTest', { '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>> _buildQueue( List entries, ) async { await castServer.ensureStarted(); _startNewOptimizationSession(); final castEntries = <_CastQueueEntry>[ const _CastQueueEntry.start(), ...entries.map(_CastQueueEntry.media), const _CastQueueEntry.end(), ]; _castQueueEntries = List<_CastQueueEntry>.unmodifiable(castEntries); final mediaItems = List?>.filled(entries.length, null); final remoteIndexes = []; // 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; 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] = { '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] = { '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( 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] = { '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>(); final resolved = >[ { 'url': castServer.buildUrlForAsset('start.jpg'), 'isVideo': false, 'mimeType': 'image/jpeg', 'title': '', }, ...resolvedMediaItems, { '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 _showHlsPreparingScreen() async { await castServer.ensureStarted(); final startUrl = castServer.buildUrlForAsset( 'start.jpg', ); final preparingItems = >[ { 'url': startUrl, 'isVideo': false, 'mimeType': 'image/jpeg', 'title': '', }, ]; resolvedUrls = [startUrl]; resolvedIsVideo = [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', { 'items': preparingItems, 'startIndex': 0, }, ); } Future startManualQueue({ required List entries, required int startIndex, }) async { if (entries.isEmpty) { return; } await _waitReady(); await castServer.ensureStarted(); final requestedEntryIndex = startIndex.clamp( 0, entries.length - 1, ); final requestedEntry = entries[requestedEntryIndex]; final requestedIsVideo = (requestedEntry.durationMillis ?? 0) > 0; 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', ); } else { await _showHlsPreparingScreen(); _queueLog( 'HLS PREPARE START ' 'entryIndex=$requestedEntryIndex ' 'mediaId=$mediaId ' 'mime=${requestedEntry.mimeType}', ); final stopwatch = Stopwatch()..start(); try { final hlsUrl = await castHlsOptimizer.prepareUntilReady( mediaId, ); stopwatch.stop(); _queueLog( 'HLS PREPARE READY ' 'entryIndex=$requestedEntryIndex ' 'mediaId=$mediaId ' '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); _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}', ); await _channel.invokeMethod( 'loadQueue', { 'items': items, 'startIndex': queueStartIndex, }, ); } Future startSlideshow({ required List 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 playCast() => _channel.invokeMethod('play'); Future pauseCast() => _channel.invokeMethod('pause'); Future 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 setCastVolume(double value) async { final clamped = value.clamp(0.0, 1.0).toDouble(); volumeNotifier.value = clamped; await _channel.invokeMethod('setVolume', {'volume': clamped}); } Future toggleMuteCast() async { final mute = !isMutedNotifier.value; await _channel.invokeMethod('setMute', {'mute': mute}); isMutedNotifier.value = mute; } Future 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 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 stopGoogleCast() async { stopSlideshow(); _clearQueueAndOptimization(); castIconState.value = CastIconState.idle; isCastingNotifier.value = false; _castReadyCompleter = Completer(); _resetPlaybackState(); return _channel.invokeMethod('stopCast'); } Future showGoogleCastDialog() { CastController.instance.castIconState.value = CastIconState.connecting; return _channel.invokeMethod('showCastDialog'); } Future>> buildQueue(List entries) { return _buildQueue(entries); } MethodChannel get channel => _channel; }