Initial commit
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
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<EmbyRawLibrary> libraries;
|
||||
final List<EmbyRawItem> resumeItems;
|
||||
final Map<String, List<EmbyRawItem>> latestByLibrary;
|
||||
final Map<String, int> totalCountByLibrary;
|
||||
final Set<String> loadingLibraryIds;
|
||||
final bool resumeLoading;
|
||||
final Object? resumeError;
|
||||
final Map<String, Object?> 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<EmbyRawLibrary>? libraries,
|
||||
List<EmbyRawItem>? resumeItems,
|
||||
Map<String, List<EmbyRawItem>>? latestByLibrary,
|
||||
Map<String, int>? totalCountByLibrary,
|
||||
Set<String>? loadingLibraryIds,
|
||||
bool? resumeLoading,
|
||||
Object? resumeError,
|
||||
bool clearResumeError = false,
|
||||
Map<String, Object?>? 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<LibraryOverviewState> {
|
||||
CancellationToken? _latestBatchToken;
|
||||
int _latestRunId = 0;
|
||||
|
||||
@override
|
||||
Future<LibraryOverviewState> 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<LibraryOverviewState> _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<void> _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<void> _loadLatestProgressivelyAndCache(
|
||||
List<EmbyRawLibrary> libraries,
|
||||
String serverId,
|
||||
String userId,
|
||||
HomePageCacheStore store,
|
||||
) async {
|
||||
await _runLatestBatches(
|
||||
libraries,
|
||||
serverId,
|
||||
userId,
|
||||
store,
|
||||
mode: _LatestUpdateMode.progressive,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _refreshLatestFromNetwork(
|
||||
List<EmbyRawLibrary> libraries,
|
||||
String serverId,
|
||||
String userId,
|
||||
HomePageCacheStore store,
|
||||
) async {
|
||||
await _runLatestBatches(
|
||||
libraries,
|
||||
serverId,
|
||||
userId,
|
||||
store,
|
||||
mode: _LatestUpdateMode.refresh,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runLatestBatches(
|
||||
List<EmbyRawLibrary> 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<bool>(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<List<_LatestResult>> _fetchLatestBatch(
|
||||
List<EmbyRawLibrary> batch,
|
||||
Future<LibraryLatestRes> 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<EmbyRawItem> 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<String, List<EmbyRawItem>>.from(current.latestByLibrary);
|
||||
final newCounts = Map<String, int>.from(current.totalCountByLibrary);
|
||||
var hasSuccess = false;
|
||||
|
||||
if (mode == _LatestUpdateMode.refresh) {
|
||||
final newLoading = Set<String>.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<String, List<EmbyRawItem>>.from(newMap),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final newLoading = Set<String>.from(current.loadingLibraryIds);
|
||||
final newErrors = Map<String, Object?>.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<String, List<EmbyRawItem>>.from(newMap),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitWhileDetailActive() async {
|
||||
if (!ref.read(detailPageActiveProvider)) return;
|
||||
final completer = Completer<void>();
|
||||
final sub = ref.listen<bool>(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<void> 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<void> 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<void> 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<void> 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<String>.from(current.loadingLibraryIds)
|
||||
..add(libraryId);
|
||||
final clearedErrors = Map<String, Object?>.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<String, List<EmbyRawItem>>.from(cur.latestByLibrary);
|
||||
newMap[libraryId] = res.items;
|
||||
final newCounts = Map<String, int>.from(cur.totalCountByLibrary);
|
||||
if (res.totalCount > 0) newCounts[libraryId] = res.totalCount;
|
||||
final stillLoading = Set<String>.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<String, List<EmbyRawItem>>.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<String>.from(cur.loadingLibraryIds)
|
||||
..remove(libraryId);
|
||||
final errs = Map<String, Object?>.from(cur.latestErrorByLibrary);
|
||||
errs[libraryId] = e;
|
||||
state = AsyncValue.data(
|
||||
cur.copyWith(
|
||||
loadingLibraryIds: stillLoading,
|
||||
latestErrorByLibrary: errs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final libraryOverviewProvider =
|
||||
AsyncNotifierProvider<LibraryOverviewNotifier, LibraryOverviewState>(
|
||||
LibraryOverviewNotifier.new,
|
||||
);
|
||||
|
||||
enum _LatestUpdateMode { progressive, refresh }
|
||||
|
||||
class _LatestResult {
|
||||
final String libraryId;
|
||||
final List<EmbyRawItem> items;
|
||||
final int totalCount;
|
||||
final Object? error;
|
||||
|
||||
const _LatestResult({
|
||||
required this.libraryId,
|
||||
required this.items,
|
||||
required this.totalCount,
|
||||
this.error,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user