import 'dart:async'; import 'dart:collection'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/contracts/library.dart'; import '../shared/utils/app_logger.dart'; import '../shared/utils/tmdb_key_utils.dart'; import 'di_providers.dart'; import 'library_overview_provider.dart'; import 'playback_active_provider.dart' show playbackActiveProvider, detailPageActiveProvider; import 'session_provider.dart'; import 'tmdb_settings_provider.dart'; final tmdbPrefetchProvider = NotifierProvider( TmdbPrefetchNotifier.new, ); class TmdbPrefetchNotifier extends Notifier { static const _requestDelay = Duration(milliseconds: 200); static const _retryDelay = Duration(seconds: 2); static const _visibleItemCount = 6; static const _maxRetries = 2; static const _initialDelay = Duration(milliseconds: 1500); final Queue _queue = Queue(); final Set _enqueuedKeys = {}; final Map _failureCount = {}; bool _disposeHookRegistered = false; bool _disposed = false; bool _isProcessing = false; bool _initialDelayElapsed = false; bool _initialDelayTimerStarted = false; bool _lastPlaybackActive = false; bool _lastDetailActive = false; String? _lastSessionServerId; List? _lastResumeRef; Map>? _lastLatestRef; @override void build() { _registerDisposeHook(); final session = ref.watch(activeSessionProvider); final serverId = session?.serverId; if (_lastSessionServerId != serverId) { _lastSessionServerId = serverId; _resetQueue(); } final playbackActive = ref.watch(playbackActiveProvider); final detailActive = ref.watch(detailPageActiveProvider); final resumedFromPlayback = _lastPlaybackActive && !playbackActive; _lastPlaybackActive = playbackActive; final resumedFromDetail = _lastDetailActive && !detailActive; _lastDetailActive = detailActive; final settings = ref.watch(tmdbSettingsProvider).value; if (!(settings?.canRequest ?? false)) return; final overview = ref.watch(libraryOverviewProvider).value; if (overview == null) return; final resumeSame = identical(_lastResumeRef, overview.resumeItems); final latestSame = identical(_lastLatestRef, overview.latestByLibrary); if (resumeSame && latestSame) { if (resumedFromPlayback || resumedFromDetail) { AppLogger.debug('prefetch', 'tmdb.build.resume_after_pause'); _scheduleProcessing(); } else { AppLogger.debug('prefetch', 'tmdb.build.skip'); } return; } _lastResumeRef = overview.resumeItems; _lastLatestRef = overview.latestByLibrary; final visibleItems = [ ...overview.resumeItems.take(_visibleItemCount), ]; for (final items in overview.latestByLibrary.values) { visibleItems.addAll(items.take(_visibleItemCount)); } _enqueueItems(visibleItems, priority: true); AppLogger.debug('prefetch', 'tmdb.build.enqueued', { 'queue': _queue.length, 'tracked': _enqueuedKeys.length, }); _scheduleProcessing(); } void prefetchItem(EmbyRawItem item, {bool priority = false}) { final key = tmdbMediaKeyFrom(item); if (key == null) return; _enqueueKey(key, priority: priority); _scheduleProcessing(); } void _registerDisposeHook() { if (_disposeHookRegistered) return; _disposeHookRegistered = true; ref.onDispose(() { _disposed = true; _resetQueue(); }); } void _resetQueue() { _queue.clear(); _enqueuedKeys.clear(); _failureCount.clear(); _lastResumeRef = null; _lastLatestRef = null; } void _enqueueItems(Iterable items, {bool priority = false}) { final keys = []; for (final item in items) { final key = tmdbMediaKeyFrom(item); if (key != null) keys.add(key); } if (priority) { for (final key in keys.reversed) { _enqueueKey(key, priority: true); } } else { for (final key in keys) { _enqueueKey(key); } } } void _enqueueKey(TmdbMediaKey key, {bool priority = false}) { if (_enqueuedKeys.contains(key)) { if (priority && _queue.remove(key)) { _queue.addFirst(key); } return; } _enqueuedKeys.add(key); if (priority) { _queue.addFirst(key); } else { _queue.add(key); } } bool get _shouldPause => ref.read(playbackActiveProvider) || ref.read(detailPageActiveProvider); void _scheduleProcessing() { if (_disposed || _isProcessing || _queue.isEmpty) return; if (_shouldPause) return; if (!_initialDelayElapsed) { _startInitialDelay(); return; } unawaited(_processQueue()); } void _startInitialDelay() { if (_initialDelayTimerStarted) return; _initialDelayTimerStarted = true; Future.delayed(_initialDelay, () { _initialDelayElapsed = true; if (_disposed) return; _scheduleProcessing(); }); } Future _processQueue() async { if (_isProcessing) return; _isProcessing = true; try { while (!_disposed && _queue.isNotEmpty) { if (!_canRequestTmdb()) break; if (_shouldPause) { AppLogger.debug('prefetch', 'tmdb.queue.suspended', { 'queue': _queue.length, }); break; } final key = _queue.removeFirst(); _enqueuedKeys.remove(key); bool requeued = false; try { final detail = await ref.read(tmdbEnrichedDetailProvider(key).future); if (_disposed) break; if (detail == null) { ref.invalidate(tmdbEnrichedDetailProvider(key)); _failureCount.remove(key); AppLogger.debug('prefetch', 'tmdb.fetch.empty', {'key': key}); } else { _failureCount.remove(key); AppLogger.debug('prefetch', 'tmdb.fetch.ok', {'key': key}); } } catch (e) { if (_disposed) break; ref.invalidate(tmdbEnrichedDetailProvider(key)); final attempts = (_failureCount[key] ?? 0) + 1; if (attempts <= _maxRetries) { _failureCount[key] = attempts; _enqueuedKeys.add(key); _queue.addLast(key); requeued = true; AppLogger.debug('prefetch', 'tmdb.fetch.retry', { 'key': key, 'attempts': attempts, 'error': e.toString(), }); } else { _failureCount.remove(key); AppLogger.debug('prefetch', 'tmdb.fetch.giveup', { 'key': key, 'error': e.toString(), }); } } if (!_disposed && _queue.isNotEmpty) { await Future.delayed(requeued ? _retryDelay : _requestDelay); } } } finally { _isProcessing = false; if (!_disposed && _queue.isNotEmpty && _canRequestTmdb() && !_shouldPause) { _scheduleProcessing(); } } } bool _canRequestTmdb() { return ref.read(tmdbSettingsProvider).value?.canRequest ?? false; } }