Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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,
});
}