import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/contracts/cancellation.dart'; import '../core/contracts/library.dart'; import '../core/infra/storage/home_page_cache_store.dart'; import '../shared/utils/app_logger.dart'; import 'di_providers.dart'; import 'playback_active_provider.dart'; import 'session_provider.dart'; class LibraryOverviewState { final List libraries; final List resumeItems; final Map> latestByLibrary; final Map totalCountByLibrary; final Set loadingLibraryIds; final bool resumeLoading; final Object? resumeError; final Map latestErrorByLibrary; const LibraryOverviewState({ this.libraries = const [], this.resumeItems = const [], this.latestByLibrary = const {}, this.totalCountByLibrary = const {}, this.loadingLibraryIds = const {}, this.resumeLoading = false, this.resumeError, this.latestErrorByLibrary = const {}, }); LibraryOverviewState copyWith({ List? libraries, List? resumeItems, Map>? latestByLibrary, Map? totalCountByLibrary, Set? loadingLibraryIds, bool? resumeLoading, Object? resumeError, bool clearResumeError = false, Map? latestErrorByLibrary, }) => LibraryOverviewState( libraries: libraries ?? this.libraries, resumeItems: resumeItems ?? this.resumeItems, latestByLibrary: latestByLibrary ?? this.latestByLibrary, totalCountByLibrary: totalCountByLibrary ?? this.totalCountByLibrary, loadingLibraryIds: loadingLibraryIds ?? this.loadingLibraryIds, resumeLoading: resumeLoading ?? this.resumeLoading, resumeError: clearResumeError ? null : (resumeError ?? this.resumeError), latestErrorByLibrary: latestErrorByLibrary ?? this.latestErrorByLibrary, ); } class LibraryOverviewNotifier extends AsyncNotifier { CancellationToken? _latestBatchToken; int _latestRunId = 0; @override Future build() async { final session = ref.watch(activeSessionProvider); if (session == null) return const LibraryOverviewState(); final store = await ref.read(homePageCacheStoreProvider.future); final cache = await store.readOverview(session.serverId, session.userId); if (cache != null) { unawaited(_refreshFromNetwork(session.serverId, session.userId)); return LibraryOverviewState( libraries: cache.libraries, resumeItems: cache.resumeItems, latestByLibrary: cache.latestByLibrary, loadingLibraryIds: const {}, resumeLoading: false, ); } return _loadPrimaryAndCache(session.serverId, session.userId); } Future _loadPrimaryAndCache( String serverId, String userId, ) async { final store = await ref.read(homePageCacheStoreProvider.future); final viewsUc = await ref.read(libraryViewsUseCaseProvider.future); final sw = Stopwatch()..start(); final views = await viewsUc.execute(); AppLogger.debug('prefetch', 'overview.views', { 'count': views.Items.length, 'ms': sw.elapsedMilliseconds, }); unawaited( store.updateOverviewFields(serverId, userId, libraries: views.Items), ); final libraryIds = views.Items.map((lib) => lib.Id).toSet(); final initialState = LibraryOverviewState( libraries: views.Items, resumeItems: const [], loadingLibraryIds: libraryIds, resumeLoading: true, ); unawaited(_loadResumeAndCache(serverId, userId, store)); unawaited( _loadLatestProgressivelyAndCache(views.Items, serverId, userId, store), ); return initialState; } Future _loadResumeAndCache( String serverId, String userId, HomePageCacheStore store, ) async { final sw = Stopwatch()..start(); try { final resumeUc = await ref.read(libraryResumeUseCaseProvider.future); final resume = await resumeUc.execute(); final current = state.value; if (current == null) return; AppLogger.debug('prefetch', 'overview.resume.ok', { 'count': resume.Items.length, 'ms': sw.elapsedMilliseconds, }); state = AsyncValue.data( current.copyWith( resumeItems: resume.Items, resumeLoading: false, clearResumeError: true, ), ); unawaited( store.updateOverviewFields(serverId, userId, resumeItems: resume.Items), ); } catch (e) { final current = state.value; if (current == null) return; AppLogger.debug('prefetch', 'overview.resume.err', { 'error': e.toString(), 'ms': sw.elapsedMilliseconds, }); state = AsyncValue.data( current.copyWith(resumeLoading: false, resumeError: e), ); } } static const _batchSize = 4; Future _loadLatestProgressivelyAndCache( List libraries, String serverId, String userId, HomePageCacheStore store, ) async { await _runLatestBatches( libraries, serverId, userId, store, mode: _LatestUpdateMode.progressive, ); } Future _refreshFromNetwork(String serverId, String userId) async { final store = await ref.read(homePageCacheStoreProvider.future); try { final viewsUc = await ref.read(libraryViewsUseCaseProvider.future); final sw = Stopwatch()..start(); final views = await viewsUc.execute(); AppLogger.debug('prefetch', 'overview.views', { 'count': views.Items.length, 'ms': sw.elapsedMilliseconds, }); if (!_sessionMatches(serverId, userId)) return; final current = state.value; if (current != null) { state = AsyncValue.data(current.copyWith(libraries: views.Items)); } unawaited( store.updateOverviewFields(serverId, userId, libraries: views.Items), ); unawaited(_refreshResumeFromNetwork(serverId, userId, store)); unawaited( _refreshLatestFromNetwork(views.Items, serverId, userId, store), ); } catch (_) {} } Future _refreshResumeFromNetwork( String serverId, String userId, HomePageCacheStore store, ) async { final sw = Stopwatch()..start(); try { final resumeUc = await ref.read(libraryResumeUseCaseProvider.future); final resume = await resumeUc.execute(); AppLogger.debug('prefetch', 'overview.resume.ok', { 'count': resume.Items.length, 'ms': sw.elapsedMilliseconds, }); if (!_sessionMatches(serverId, userId)) return; final current = state.value; if (current == null) return; state = AsyncValue.data( current.copyWith( resumeItems: resume.Items, resumeLoading: false, clearResumeError: true, ), ); unawaited( store.updateOverviewFields(serverId, userId, resumeItems: resume.Items), ); } catch (_) {} } Future _refreshLatestFromNetwork( List libraries, String serverId, String userId, HomePageCacheStore store, ) async { await _runLatestBatches( libraries, serverId, userId, store, mode: _LatestUpdateMode.refresh, ); } Future _runLatestBatches( List libraries, String serverId, String userId, HomePageCacheStore store, { required _LatestUpdateMode mode, }) async { final runId = ++_latestRunId; _latestBatchToken?.cancel('latest_replaced'); _latestBatchToken = null; final latestUc = await ref.read(libraryLatestUseCaseProvider.future); for (var i = 0; i < libraries.length;) { if (runId != _latestRunId || !_sessionMatches(serverId, userId)) return; await _waitWhileDetailActive(); if (runId != _latestRunId || !_sessionMatches(serverId, userId)) return; final batch = libraries.sublist( i, (i + _batchSize).clamp(0, libraries.length), ); final token = CancellationToken(); _latestBatchToken = token; final sub = ref.listen(detailPageActiveProvider, (_, active) { if (active) token.cancel('detail_active'); }); try { if (ref.read(detailPageActiveProvider)) { token.cancel('detail_active'); } token.throwIfCancelled(); final results = await _fetchLatestBatch(batch, latestUc.execute, token); token.throwIfCancelled(); if (runId != _latestRunId || !_sessionMatches(serverId, userId)) { return; } _applyLatestResults(results, serverId, userId, store, mode: mode); i += _batchSize; } on CancelledException catch (e) { if (runId != _latestRunId) return; AppLogger.debug('prefetch', 'overview.latest.cancel', { 'index': i, 'reason': e.reason ?? token.reason ?? 'cancelled', }); } finally { sub.close(); if (identical(_latestBatchToken, token)) { _latestBatchToken = null; } } } } Future> _fetchLatestBatch( List batch, Future Function( LibraryLatestReq input, { CancellationToken? cancelToken, }) execute, CancellationToken cancelToken, ) async { final results = <_LatestResult>[]; final futures = batch.map((lib) async { cancelToken.throwIfCancelled(); final sw = Stopwatch()..start(); List items = const []; int totalCount = 0; Object? err; try { final res = await execute( LibraryLatestReq(parentId: lib.Id, limit: 16), cancelToken: cancelToken, ); cancelToken.throwIfCancelled(); items = res.items; totalCount = res.totalCount; } on CancelledException { rethrow; } catch (e) { err = e; } AppLogger.debug('prefetch', err == null ? 'overview.latest.ok' : 'overview.latest.err', { 'lib': lib.Name, 'count': items.length, if (err != null) 'error': err.toString(), 'ms': sw.elapsedMilliseconds, }, ); results.add( _LatestResult( libraryId: lib.Id, items: items, totalCount: totalCount, error: err, ), ); }).toList(); await Future.wait(futures); return results; } void _applyLatestResults( List<_LatestResult> results, String serverId, String userId, HomePageCacheStore store, { required _LatestUpdateMode mode, }) { final current = state.value; if (current == null) return; final newMap = Map>.from(current.latestByLibrary); final newCounts = Map.from(current.totalCountByLibrary); var hasSuccess = false; if (mode == _LatestUpdateMode.refresh) { final newLoading = Set.from(current.loadingLibraryIds); for (final r in results) { if (r.error != null) continue; newMap[r.libraryId] = r.items; if (r.totalCount > 0) newCounts[r.libraryId] = r.totalCount; newLoading.remove(r.libraryId); hasSuccess = true; } state = AsyncValue.data( current.copyWith( latestByLibrary: newMap, totalCountByLibrary: newCounts, loadingLibraryIds: newLoading, ), ); if (hasSuccess) { unawaited( store.updateOverviewFields( serverId, userId, latestByLibrary: Map>.from(newMap), ), ); } return; } final newLoading = Set.from(current.loadingLibraryIds); final newErrors = Map.from(current.latestErrorByLibrary); for (final r in results) { newMap[r.libraryId] = r.items; if (r.totalCount > 0) newCounts[r.libraryId] = r.totalCount; newLoading.remove(r.libraryId); if (r.error != null) { newErrors[r.libraryId] = r.error; } else { newErrors.remove(r.libraryId); hasSuccess = true; } } state = AsyncValue.data( current.copyWith( latestByLibrary: newMap, totalCountByLibrary: newCounts, loadingLibraryIds: newLoading, latestErrorByLibrary: newErrors, ), ); if (hasSuccess) { unawaited( store.updateOverviewFields( serverId, userId, latestByLibrary: Map>.from(newMap), ), ); } } Future _waitWhileDetailActive() async { if (!ref.read(detailPageActiveProvider)) return; final completer = Completer(); final sub = ref.listen(detailPageActiveProvider, (_, active) { if (!active && !completer.isCompleted) { completer.complete(); } }); await completer.future; sub.close(); } bool _sessionMatches(String serverId, String userId) { final s = ref.read(activeSessionProvider); return s != null && s.serverId == serverId && s.userId == userId; } Future refresh() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { final session = ref.read(activeSessionProvider); if (session == null) return const LibraryOverviewState(); return _loadPrimaryAndCache(session.serverId, session.userId); }); } Future softRefresh() async { final session = ref.read(activeSessionProvider); if (session == null) return; if (state.value == null) { await refresh(); return; } await _refreshFromNetwork(session.serverId, session.userId); } Future retryResume() async { final session = ref.read(activeSessionProvider); if (session == null) return; final store = await ref.read(homePageCacheStoreProvider.future); final current = state.value; if (current == null) return; state = AsyncValue.data( current.copyWith(resumeLoading: true, clearResumeError: true), ); await _loadResumeAndCache(session.serverId, session.userId, store); } Future retryLatest(String libraryId) async { final session = ref.read(activeSessionProvider); if (session == null) return; final store = await ref.read(homePageCacheStoreProvider.future); final current = state.value; if (current == null) return; final newLoading = Set.from(current.loadingLibraryIds) ..add(libraryId); final clearedErrors = Map.from( current.latestErrorByLibrary, )..remove(libraryId); state = AsyncValue.data( current.copyWith( loadingLibraryIds: newLoading, latestErrorByLibrary: clearedErrors, ), ); final uc = await ref.read(libraryLatestUseCaseProvider.future); final sw = Stopwatch()..start(); try { final res = await uc.execute( LibraryLatestReq(parentId: libraryId, limit: 16), ); AppLogger.debug('prefetch', 'overview.latest.retry.ok', { 'lib': libraryId, 'count': res.items.length, 'ms': sw.elapsedMilliseconds, }); final cur = state.value; if (cur == null) return; final newMap = Map>.from(cur.latestByLibrary); newMap[libraryId] = res.items; final newCounts = Map.from(cur.totalCountByLibrary); if (res.totalCount > 0) newCounts[libraryId] = res.totalCount; final stillLoading = Set.from(cur.loadingLibraryIds) ..remove(libraryId); state = AsyncValue.data( cur.copyWith( latestByLibrary: newMap, totalCountByLibrary: newCounts, loadingLibraryIds: stillLoading, ), ); unawaited( store.updateOverviewFields( session.serverId, session.userId, latestByLibrary: Map>.from(newMap), ), ); } catch (e) { AppLogger.debug('prefetch', 'overview.latest.retry.err', { 'lib': libraryId, 'error': e.toString(), 'ms': sw.elapsedMilliseconds, }); final cur = state.value; if (cur == null) return; final stillLoading = Set.from(cur.loadingLibraryIds) ..remove(libraryId); final errs = Map.from(cur.latestErrorByLibrary); errs[libraryId] = e; state = AsyncValue.data( cur.copyWith( loadingLibraryIds: stillLoading, latestErrorByLibrary: errs, ), ); } } } final libraryOverviewProvider = AsyncNotifierProvider( LibraryOverviewNotifier.new, ); enum _LatestUpdateMode { progressive, refresh } class _LatestResult { final String libraryId; final List items; final int totalCount; final Object? error; const _LatestResult({ required this.libraryId, required this.items, required this.totalCount, this.error, }); }