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
+76
View File
@@ -0,0 +1,76 @@
part of '../di_providers.dart';
final appDataDirProvider = FutureProvider<Directory>((ref) async {
final base = await getApplicationSupportDirectory();
final dir = Directory('${base.path}/smplayer');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
});
final deviceIdProvider = FutureProvider<String>((ref) async {
final store = DeviceIdStore();
return store.getOrCreate();
});
final serverRepositoryProvider = FutureProvider<ServerRepository>((ref) async {
final dir = await ref.watch(appDataDirProvider.future);
return JsonServerRepository(file: File('${dir.path}/servers.json'));
});
final sessionRepositoryProvider = FutureProvider<SessionRepository>((
ref,
) async {
final dir = await ref.watch(appDataDirProvider.future);
return JsonSessionRepository(file: File('${dir.path}/sessions.json'));
});
final searchHistoryStoreProvider = Provider<SearchHistoryStore>(
(ref) => SearchHistoryStore(),
);
final danmakuSourcesStoreProvider = Provider<DanmakuSourcesStore>(
(ref) => PrefsDanmakuSourcesStore(),
);
final homePageCacheStoreProvider = FutureProvider<HomePageCacheStore>((
ref,
) async {
final dir = await ref.watch(appDataDirProvider.future);
return HomePageCacheStore(directory: dir);
});
final discoverPageCacheStoreProvider = FutureProvider<DiscoverPageCacheStore>((
ref,
) async {
final dir = await ref.watch(appDataDirProvider.future);
return DiscoverPageCacheStore(directory: dir);
});
final embyGatewayProvider = FutureProvider<EmbyGateway>((ref) async {
final deviceId = await ref.read(deviceIdProvider.future);
return EmbyApiClient(deviceId: deviceId, deviceName: kEmbyDeviceName);
});
final tmdbGatewayProvider = Provider<TmdbGateway>((ref) => TmdbApiClient());
final danmakuGatewayProvider = Provider<DanmakuGateway>(
(ref) => DispatchingDanmakuGateway(
httpGateway: DanmakuApiClient(),
scriptWidgetGateway: ScriptWidgetDanmakuGateway(
loadService: () => ref.read(scriptWidgetServiceProvider.future),
),
),
);
+271
View File
@@ -0,0 +1,271 @@
part of '../di_providers.dart';
typedef SeriesSeasonsKey = ({String serverId, String seriesId});
typedef EpisodesKey = ({String serverId, String seriesId, String seasonId});
typedef CrossServerSearchKey = ({
String activeServerId,
String anchorType,
String providerIdQuery,
String seriesProviderIdQuery,
int parentIndexNumber,
int indexNumber,
String itemName,
String excludedServerId,
});
typedef MediaDetailKey = ({String serverId, String itemId});
final mediaDetailDataProvider =
FutureProvider.family<MediaDetailRes, MediaDetailKey>((ref, key) async {
final uc = await ref.read(mediaDetailUseCaseProvider.future);
final sw = Stopwatch()..start();
final res = await uc
.execute(MediaDetailReq(itemId: key.itemId))
.timeout(const Duration(seconds: 30));
final rawSources = res.base.extra['MediaSources'];
final srcCount = rawSources is List ? rawSources.length : 0;
AppLogger.debug(
'DetailTiming',
'base.fetch type=${res.base.Type} sources=$srcCount ms=${sw.elapsedMilliseconds}',
);
return res;
});
final similarItemsDataProvider =
FutureProvider.family<List<EmbyRawItem>, String>((ref, itemId) async {
final uc = await ref.read(similarItemsUseCaseProvider.future);
final res = await uc.execute(SimilarItemsReq(itemId: itemId));
return res.items;
});
final additionalPartsDataProvider =
FutureProvider.family<List<EmbyRawItem>, String>((ref, itemId) async {
final uc = await ref.read(additionalPartsUseCaseProvider.future);
final res = await uc.execute(AdditionalPartsReq(itemId: itemId));
return res.items;
});
final specialFeaturesDataProvider = FutureProvider.autoDispose
.family<List<EmbyRawItem>, String>((ref, itemId) async {
final uc = await ref.read(specialFeaturesUseCaseProvider.future);
return uc.execute(itemId);
});
final crossServerSearchDataProvider = StreamProvider.autoDispose
.family<List<CrossServerSourceCard>, CrossServerSearchKey>((
ref,
key,
) async* {
final uc = await ref.read(crossServerSearchUseCaseProvider.future);
final cancelToken = CancellationToken();
ref.onDispose(() => cancelToken.cancel('provider disposed'));
final req = CrossServerSearchReq(
anchorType: key.anchorType,
itemName: key.itemName,
providerIds: _decodeProviderIdQuery(key.providerIdQuery),
seriesProviderIds: key.seriesProviderIdQuery.isEmpty
? null
: _decodeProviderIdQuery(key.seriesProviderIdQuery),
parentIndexNumber: key.parentIndexNumber < 0
? null
: key.parentIndexNumber,
indexNumber: key.indexNumber < 0 ? null : key.indexNumber,
excludedServerId: key.excludedServerId.isEmpty
? null
: key.excludedServerId,
);
yield const <CrossServerSourceCard>[];
yield* uc.executeStream(req, cancelToken: cancelToken);
});
final tmdbLibraryAvailabilityProvider = StreamProvider.autoDispose
.family<
List<TmdbLibraryHit>,
({String mediaType, String tmdbId, String imdbId})
>((ref, key) async* {
if (key.tmdbId.isEmpty) {
yield const <TmdbLibraryHit>[];
return;
}
final uc = await ref.read(tmdbAvailabilityUseCaseProvider.future);
final cancelToken = CancellationToken();
ref.onDispose(() => cancelToken.cancel('provider disposed'));
yield* uc.executeStream(
TmdbAvailabilityReq(
tmdbId: key.tmdbId,
mediaType: key.mediaType,
imdbId: key.imdbId.isEmpty ? null : key.imdbId,
),
cancelToken: cancelToken,
);
});
typedef ServerScopedItemKey = ({String serverId, String itemId});
final tmdbServerScopedDetailProvider = FutureProvider.autoDispose
.family<MediaDetailRes, ServerScopedItemKey>((ref, key) async {
final uc = await ref.read(mediaDetailUseCaseProvider.future);
return uc.executeForServer(serverId: key.serverId, itemId: key.itemId);
});
typedef ServerScopedAuth = ({
String baseUrl,
String token,
String userId,
Map<String, String> imageHeaders,
});
final tmdbServerScopedAuthProvider = FutureProvider.autoDispose
.family<ServerScopedAuth?, String>((ref, serverId) async {
final sessionRepo = await ref.read(sessionRepositoryProvider.future);
final serverRepo = await ref.read(serverRepositoryProvider.future);
final session = await sessionRepo.getByServerId(serverId);
if (session == null) return null;
final server = await serverRepo.findById(serverId);
if (server == null) return null;
final deviceId = await ref.read(deviceIdProvider.future);
return (
baseUrl: server.baseUrl,
token: session.token,
userId: session.userId,
imageHeaders: EmbyRequestHeaders.image(
deviceId: deviceId,
deviceName: kEmbyDeviceName,
token: session.token,
userId: session.userId,
),
);
});
final tmdbServerScopedAdditionalPartsProvider = FutureProvider.autoDispose
.family<List<EmbyRawItem>, ServerScopedItemKey>((ref, key) async {
final uc = await ref.read(additionalPartsUseCaseProvider.future);
final res = await uc.executeForServer(
serverId: key.serverId,
itemId: key.itemId,
);
return res.items;
});
typedef SeriesPrewarmKey = ({
String activeServerId,
String seriesProviderIdQuery,
});
final seriesPrewarmProvider = FutureProvider.autoDispose
.family<void, SeriesPrewarmKey>((ref, key) async {
if (key.seriesProviderIdQuery.isEmpty) return;
final keepAlive = ref.keepAlive();
final uc = await ref.read(crossServerSearchUseCaseProvider.future);
final cancelToken = CancellationToken();
var disposed = false;
ref.onDispose(() {
disposed = true;
cancelToken.cancel('prewarm disposed');
});
try {
await uc.prewarmSeriesIdentity(
_decodeProviderIdQuery(key.seriesProviderIdQuery),
cancelToken: cancelToken,
);
} finally {
if (!disposed) keepAlive.close();
}
});
Map<String, String> _decodeProviderIdQuery(String q) {
final out = <String, String>{};
if (q.isEmpty) return out;
for (final part in q.split(',')) {
final dotIdx = part.indexOf('.');
if (dotIdx > 0) {
out[part.substring(0, dotIdx)] = part.substring(dotIdx + 1);
}
}
return out;
}
final seriesSeasonsDataProvider =
FutureProvider.family<List<EmbyRawSeason>, SeriesSeasonsKey>((
ref,
key,
) async {
final uc = await ref.read(seriesSeasonsUseCaseProvider.future);
final res = await uc.executeForServer(
serverId: key.serverId,
input: SeriesSeasonsReq(seriesId: key.seriesId),
);
return res.items;
});
final seriesEpisodesDataProvider =
FutureProvider.family<List<EmbyRawItem>, EpisodesKey>((ref, key) async {
final uc = await ref.read(seriesEpisodesUseCaseProvider.future);
final res = await uc.executeForServer(
serverId: key.serverId,
input: MediaEpisodesReq(seriesId: key.seriesId, seasonId: key.seasonId),
);
return res.items;
});
typedef SeriesResumeTargetKey = ({String serverId, String seriesId});
final seriesResumeTargetProvider = FutureProvider.autoDispose
.family<SeriesResumeTarget?, SeriesResumeTargetKey>((ref, key) async {
try {
final sessionRepo = await ref.read(sessionRepositoryProvider.future);
final serverRepo = await ref.read(serverRepositoryProvider.future);
final ctx = await resolveAuthedContextForServer(
sessionRepository: sessionRepo,
serverRepository: serverRepo,
serverId: key.serverId,
);
if (ctx == null) return null;
final gw = await ref.read(embyGatewayProvider.future);
return resolveSeriesResumeTarget(gw, ctx, key.seriesId);
} catch (e) {
AppLogger.warn('SeriesResumeTarget', 'resolve failed', e);
return null;
}
});
final tmdbTvRecentHitProvider = FutureProvider.autoDispose
.family<
TmdbLibraryHit?,
({String mediaType, String tmdbId, String imdbId})
>((ref, key) async {
final hits = await ref.watch(tmdbLibraryAvailabilityProvider(key).future);
final seriesHits = hits
.where((h) => h.itemType == 'Series')
.toList(growable: false);
if (seriesHits.length < 2) return null;
final uc = await ref.read(mediaDetailUseCaseProvider.future);
DateTime? bestDate;
TmdbLibraryHit? best;
for (final h in seriesHits) {
try {
final res = await uc.executeForServer(
serverId: h.serverId,
itemId: h.itemId,
);
final lp = parseEmbyDate(res.base.UserData?.extra['LastPlayedDate']);
if (lp != null && (bestDate == null || lp.isAfter(bestDate))) {
bestDate = lp;
best = h;
}
} catch (_) {}
}
return best;
});
+261
View File
@@ -0,0 +1,261 @@
part of '../di_providers.dart';
final playbackResolverProvider = FutureProvider<PlaybackResolver>((ref) async {
final gateway = await ref.read(embyGatewayProvider.future);
final deviceId = await ref.read(deviceIdProvider.future);
return PlaybackResolver(
gateway: gateway,
deviceId: deviceId,
deviceName: kEmbyDeviceName,
);
});
typedef _EmbyDeps = ({
SessionRepository session,
ServerRepository server,
EmbyGateway gw,
});
Future<_EmbyDeps> _embyDeps(Ref ref) async => (
session: await ref.watch(sessionRepositoryProvider.future),
server: await ref.watch(serverRepositoryProvider.future),
gw: await ref.watch(embyGatewayProvider.future),
);
FutureProvider<T> _authedUseCaseProvider<T>(
T Function({
required SessionRepository sessionRepository,
required ServerRepository serverRepository,
required EmbyGateway embyGateway,
})
ctor,
) {
return FutureProvider<T>((ref) async {
final d = await _embyDeps(ref);
return ctor(
sessionRepository: d.session,
serverRepository: d.server,
embyGateway: d.gw,
);
});
}
final listServersUseCaseProvider = FutureProvider<ListServersUseCase>((
ref,
) async {
final repo = await ref.watch(serverRepositoryProvider.future);
return ListServersUseCase(serverRepository: repo);
});
final saveServerUseCaseProvider = FutureProvider<SaveServerUseCase>((
ref,
) async {
final repo = await ref.watch(serverRepositoryProvider.future);
return SaveServerUseCase(serverRepository: repo);
});
final reorderServersUseCaseProvider = FutureProvider<ReorderServersUseCase>((
ref,
) async {
final repo = await ref.watch(serverRepositoryProvider.future);
return ReorderServersUseCase(serverRepository: repo);
});
final probeServerUseCaseProvider = FutureProvider<ProbeServerUseCase>((
ref,
) async {
final gw = await ref.watch(embyGatewayProvider.future);
return ProbeServerUseCase(embyGateway: gw);
});
final exportBackupUseCaseProvider = FutureProvider<ExportBackupUseCase>((
ref,
) async {
return ExportBackupUseCase(
serverRepository: await ref.watch(serverRepositoryProvider.future),
sessionRepository: await ref.watch(sessionRepositoryProvider.future),
danmakuSourcesStore: ref.read(danmakuSourcesStoreProvider),
scriptWidgetRepository: await ref.watch(
scriptWidgetRepositoryProvider.future,
),
);
});
final importBackupUseCaseProvider = FutureProvider<ImportBackupUseCase>((
ref,
) async {
return ImportBackupUseCase(
serverRepository: await ref.watch(serverRepositoryProvider.future),
sessionRepository: await ref.watch(sessionRepositoryProvider.future),
danmakuSourcesStore: ref.read(danmakuSourcesStoreProvider),
scriptWidgetRepository: await ref.watch(
scriptWidgetRepositoryProvider.future,
),
);
});
final serverSyncGatewayProvider = Provider<ServerSyncGateway>(
(ref) => ServerSyncClient(),
);
final uploadServerSyncUseCaseProvider = FutureProvider<UploadServerSyncUseCase>(
(ref) async {
return UploadServerSyncUseCase(
export: await ref.watch(exportBackupUseCaseProvider.future),
gateway: ref.read(serverSyncGatewayProvider),
);
},
);
final pullServerSyncUseCaseProvider = FutureProvider<PullServerSyncUseCase>((
ref,
) async {
return PullServerSyncUseCase(
serverRepository: await ref.watch(serverRepositoryProvider.future),
sessionRepository: await ref.watch(sessionRepositoryProvider.future),
danmakuSourcesStore: ref.read(danmakuSourcesStoreProvider),
scriptWidgetRepository: await ref.watch(
scriptWidgetRepositoryProvider.future,
),
gateway: ref.read(serverSyncGatewayProvider),
);
});
final repairSessionsUseCaseProvider = FutureProvider<RepairSessionsUseCase>((
ref,
) async {
final d = await _embyDeps(ref);
return RepairSessionsUseCase(
serverRepository: d.server,
sessionRepository: d.session,
embyGateway: d.gw,
);
});
final deleteServerUseCaseProvider = FutureProvider<DeleteServerUseCase>((
ref,
) async {
final sRepo = await ref.watch(serverRepositoryProvider.future);
final ssRepo = await ref.watch(sessionRepositoryProvider.future);
return DeleteServerUseCase(
serverRepository: sRepo,
sessionRepository: ssRepo,
);
});
final togglePauseServerUseCaseProvider =
FutureProvider<TogglePauseServerUseCase>((ref) async {
final sRepo = await ref.watch(serverRepositoryProvider.future);
return TogglePauseServerUseCase(serverRepository: sRepo);
});
final authByNameUseCaseProvider = FutureProvider<AuthByNameUseCase>((
ref,
) async {
final d = await _embyDeps(ref);
return AuthByNameUseCase(
serverRepository: d.server,
sessionRepository: d.session,
embyGateway: d.gw,
);
});
final logoutUseCaseProvider = FutureProvider<LogoutUseCase>((ref) async {
return LogoutUseCase(
sessionRepository: await ref.watch(sessionRepositoryProvider.future),
);
});
final listSessionsUseCaseProvider = FutureProvider<ListSessionsUseCase>((
ref,
) async {
return ListSessionsUseCase(
sessionRepository: await ref.watch(sessionRepositoryProvider.future),
serverRepository: await ref.watch(serverRepositoryProvider.future),
);
});
final switchSessionUseCaseProvider = FutureProvider<SwitchSessionUseCase>((
ref,
) async {
return SwitchSessionUseCase(
sessionRepository: await ref.watch(sessionRepositoryProvider.future),
);
});
final changePasswordUseCaseProvider = FutureProvider<ChangePasswordUseCase>((
ref,
) async {
final d = await _embyDeps(ref);
return ChangePasswordUseCase(
sessionRepository: d.session,
serverRepository: d.server,
embyGateway: d.gw,
);
});
final libraryViewsUseCaseProvider = _authedUseCaseProvider(
GetLibraryViewsUseCase.new,
);
final libraryResumeUseCaseProvider = _authedUseCaseProvider(
GetLibraryResumeUseCase.new,
);
final libraryLatestUseCaseProvider = _authedUseCaseProvider(
GetLibraryLatestUseCase.new,
);
final libraryItemsUseCaseProvider = _authedUseCaseProvider(
GetLibraryItemsUseCase.new,
);
final homeBannerUseCaseProvider = _authedUseCaseProvider(
GetHomeBannerUseCase.new,
);
final mediaDetailUseCaseProvider = _authedUseCaseProvider(
GetMediaDetailUseCase.new,
);
final setFavoriteUseCaseProvider = _authedUseCaseProvider(
SetFavoriteUseCase.new,
);
final setPlayedUseCaseProvider = _authedUseCaseProvider(SetPlayedUseCase.new);
final hideFromResumeUseCaseProvider = _authedUseCaseProvider(
HideFromResumeUseCase.new,
);
final seriesEpisodesUseCaseProvider = _authedUseCaseProvider(
GetSeriesEpisodesUseCase.new,
);
final seriesSeasonsUseCaseProvider = _authedUseCaseProvider(
GetSeriesSeasonsUseCase.new,
);
final similarItemsUseCaseProvider = _authedUseCaseProvider(
GetSimilarItemsUseCase.new,
);
final additionalPartsUseCaseProvider = _authedUseCaseProvider(
GetAdditionalPartsUseCase.new,
);
final specialFeaturesUseCaseProvider = _authedUseCaseProvider(
GetSpecialFeaturesUseCase.new,
);
final crossServerSearchUseCaseProvider = _authedUseCaseProvider(
CrossServerSearchUseCase.new,
);
final tmdbAvailabilityUseCaseProvider = _authedUseCaseProvider(
TmdbAvailabilityUseCase.new,
);
final globalKeywordSearchUseCaseProvider = _authedUseCaseProvider(
GlobalKeywordSearchUseCase.new,
);
final aggregatedResumeUseCaseProvider = _authedUseCaseProvider(
GetAggregatedResumeUseCase.new,
);
final aggregatedFavoritesUseCaseProvider = _authedUseCaseProvider(
GetAggregatedFavoritesUseCase.new,
);
final aggregatedRecentUseCaseProvider = _authedUseCaseProvider(
GetAggregatedRecentUseCase.new,
);
+84
View File
@@ -0,0 +1,84 @@
part of '../di_providers.dart';
final scriptWidgetRuntimeProvider = Provider<ScriptWidgetRuntime>((ref) {
final runtime = QuickJsWidgetRuntime();
ref.onDispose(() {
unawaited(runtime.dispose());
});
return runtime;
});
final scriptWidgetRemoteGatewayProvider = Provider<ScriptWidgetRemoteGateway>(
(ref) => ScriptWidgetRemoteClient(),
);
final scriptWidgetRepositoryProvider = FutureProvider<ScriptWidgetRepository>((
ref,
) async {
final appDataDirectory = await ref.watch(appDataDirProvider.future);
return JsonScriptWidgetRepository(
file: File('${appDataDirectory.path}/script_widgets.json'),
);
});
final scriptWidgetServiceProvider = FutureProvider<ScriptWidgetService>((
ref,
) async {
final repository = await ref.watch(scriptWidgetRepositoryProvider.future);
return ScriptWidgetService(
repository: repository,
remoteGateway: ref.watch(scriptWidgetRemoteGatewayProvider),
runtime: ref.watch(scriptWidgetRuntimeProvider),
danmakuSourcesStore: ref.watch(danmakuSourcesStoreProvider),
onDanmakuSourcesChanged: () async {
ref.invalidate(danmakuSettingsProvider);
},
);
});
final installedScriptWidgetsProvider =
FutureProvider<List<InstalledScriptWidget>>((ref) async {
final service = await ref.watch(scriptWidgetServiceProvider.future);
return service.listWidgets();
});
final scriptWidgetSubscriptionsProvider =
FutureProvider<List<ScriptWidgetSubscription>>((ref) async {
final service = await ref.watch(scriptWidgetServiceProvider.future);
return service.listSubscriptions();
});
final installedScriptWidgetProvider =
FutureProvider.family<InstalledScriptWidget, String>((ref, widgetId) async {
final service = await ref.watch(scriptWidgetServiceProvider.future);
return service.requireWidget(widgetId);
});
typedef ScriptWidgetModulePreviewKey = ({String widgetId, String moduleKey});
final scriptWidgetModulePreviewProvider =
FutureProvider.family<
List<ScriptWidgetContentSection>,
ScriptWidgetModulePreviewKey
>((ref, previewKey) async {
final service = await ref.watch(scriptWidgetServiceProvider.future);
final installedWidget = await service.requireWidget(previewKey.widgetId);
final module = installedWidget.manifest.modules
.where(
(candidate) =>
(candidate.id.isEmpty
? candidate.functionName
: candidate.id) ==
previewKey.moduleKey,
)
.firstOrNull;
if (module == null) {
throw StateError('未找到模块功能:${previewKey.moduleKey}');
}
return service.loadModuleSections(
previewKey.widgetId,
module,
const <String, Object?>{},
);
});
+362
View File
@@ -0,0 +1,362 @@
part of '../di_providers.dart';
typedef TmdbMediaKey = ({String tmdbId, String mediaType});
typedef TmdbSeasonKey = ({String tmdbId, int seasonNumber});
typedef _TmdbReady = ({TmdbRequestConfig config, String language});
final _tmdbReadyProvider = FutureProvider<_TmdbReady?>((ref) async {
final settings = await ref.watch(tmdbSettingsProvider.future);
if (!settings.canRequest) return null;
if (settings.accessMode == TmdbAccessMode.smAccount) {
final account = await ref.watch(smAccountProvider.future);
if (!account.canUseDataPlane) return null;
final notifier = ref.read(smAccountProvider.notifier);
final token = await notifier.getAccessToken();
if (token == null) return null;
return (
config: TmdbRequestConfig(
apiBaseUrl: account.tmdbApiBaseUrl,
imageBaseUrl: TmdbSettingsState.officialImageBaseUrl,
accessToken: token,
apiKey: '',
onUnauthorized: () => notifier.getAccessToken(force: true),
),
language: settings.language,
);
}
return (
config: TmdbRequestConfig(
apiBaseUrl: settings.effectiveApiBaseUrl,
imageBaseUrl: settings.effectiveImageBaseUrl,
accessToken: settings.effectiveAccessToken,
apiKey: settings.effectiveApiKey,
),
language: settings.language,
);
});
final _tmdbCacheScopeProvider = FutureProvider<DiscoverPageCacheScope?>((
ref,
) async {
final settings = await ref.watch(tmdbSettingsProvider.future);
if (!settings.canRequest) return null;
if (settings.accessMode == TmdbAccessMode.smAccount) {
final account = await ref.watch(smAccountProvider.future);
if (!account.hasActiveSession) return null;
return DiscoverPageCacheScope(
apiBaseUrl: account.tmdbApiBaseUrl,
imageBaseUrl: TmdbSettingsState.officialImageBaseUrl,
language: settings.language,
);
}
return DiscoverPageCacheScope(
apiBaseUrl: settings.effectiveApiBaseUrl,
imageBaseUrl: settings.effectiveImageBaseUrl,
language: settings.language,
);
});
final tmdbEnrichedDetailProvider =
FutureProvider.family<TmdbEnrichedDetail?, TmdbMediaKey>((ref, key) async {
final ready = await ref.watch(_tmdbReadyProvider.future);
if (ready == null || key.tmdbId.isEmpty) return null;
return ref
.read(tmdbGatewayProvider)
.getEnrichedDetail(
ready.config,
key.tmdbId,
key.mediaType,
language: ready.language,
);
});
final tmdbImagesProvider = Provider.family<TmdbImageSet?, TmdbMediaKey>((
ref,
key,
) {
return ref.watch(tmdbEnrichedDetailProvider(key)).value?.images;
});
final tmdbMediaDetailProvider = Provider.family<TmdbMediaDetail?, TmdbMediaKey>(
(ref, key) {
return ref.watch(tmdbEnrichedDetailProvider(key)).value?.detail;
},
);
final tmdbSeasonDetailProvider =
FutureProvider.family<TmdbSeasonDetail?, TmdbSeasonKey>((ref, key) async {
final ready = await ref.watch(_tmdbReadyProvider.future);
if (ready == null || key.tmdbId.isEmpty) return null;
return ref
.read(tmdbGatewayProvider)
.getSeasonDetail(
ready.config,
key.tmdbId,
key.seasonNumber,
language: ready.language,
);
});
enum _TmdbRecommendationRowKind {
trendingDay,
trendingWeek,
popularMovies,
popularTv,
airingTodayTv,
topRatedMovies,
}
final _tmdbRecommendationRowProvider =
AsyncNotifierProvider.family<
_TmdbRecommendationRowNotifier,
TmdbRecommendationsRes?,
_TmdbRecommendationRowKind
>(_TmdbRecommendationRowNotifier.new);
final tmdbTrendingDayProvider = _tmdbRecommendationRowProvider(
_TmdbRecommendationRowKind.trendingDay,
);
final tmdbTrendingWeekProvider = _tmdbRecommendationRowProvider(
_TmdbRecommendationRowKind.trendingWeek,
);
final tmdbPopularMoviesProvider = _tmdbRecommendationRowProvider(
_TmdbRecommendationRowKind.popularMovies,
);
final tmdbPopularTvProvider = _tmdbRecommendationRowProvider(
_TmdbRecommendationRowKind.popularTv,
);
final tmdbAiringTodayTvProvider = _tmdbRecommendationRowProvider(
_TmdbRecommendationRowKind.airingTodayTv,
);
final tmdbTopRatedMoviesProvider = _tmdbRecommendationRowProvider(
_TmdbRecommendationRowKind.topRatedMovies,
);
typedef TmdbDiscoverKey = ({
String mediaType,
String sortBy,
String? withGenres,
String? voteCountGte,
});
final tmdbDiscoverProvider =
AsyncNotifierProvider.family<
_TmdbDiscoverRowNotifier,
TmdbRecommendationsRes?,
TmdbDiscoverKey
>(_TmdbDiscoverRowNotifier.new);
class _TmdbRecommendationRowNotifier
extends AsyncNotifier<TmdbRecommendationsRes?> {
final _TmdbRecommendationRowKind arg;
_TmdbRecommendationRowNotifier(this.arg);
@override
Future<TmdbRecommendationsRes?> build() async {
final rowKey = _rowKeyForKind(arg);
final scope = await ref.watch(_tmdbCacheScopeProvider.future);
if (scope == null) return null;
final cached = await _readDiscoverRowCacheByScope(ref, scope, rowKey);
if (cached != null) {
unawaited(_refreshFromNetwork(arg, rowKey));
return cached;
}
final ready = await ref.watch(_tmdbReadyProvider.future);
if (ready == null) return null;
return _fetchAndCache(arg, ready, rowKey);
}
Future<void> _refreshFromNetwork(
_TmdbRecommendationRowKind kind,
String rowKey,
) async {
try {
final ready = await ref.read(_tmdbReadyProvider.future);
if (ready == null) return;
final fresh = await _fetchAndCache(kind, ready, rowKey);
if (fresh != null) {
state = AsyncValue.data(fresh);
}
} catch (e) {
AppLogger.warn('TMDB', 'refresh discover row $rowKey failed', e);
}
}
Future<TmdbRecommendationsRes?> _fetchAndCache(
_TmdbRecommendationRowKind kind,
_TmdbReady ready,
String rowKey,
) async {
final fresh = await _fetchRow(kind, ready);
if (fresh != null) {
await _writeDiscoverRowCache(ref, ready, rowKey, fresh);
}
return fresh;
}
Future<TmdbRecommendationsRes?> _fetchRow(
_TmdbRecommendationRowKind kind,
_TmdbReady ready,
) {
final gateway = ref.read(tmdbGatewayProvider);
return switch (kind) {
_TmdbRecommendationRowKind.trendingDay => gateway.getTrending(
ready.config,
language: ready.language,
mediaType: 'all',
timeWindow: 'day',
),
_TmdbRecommendationRowKind.trendingWeek => gateway.getTrending(
ready.config,
language: ready.language,
mediaType: 'all',
timeWindow: 'week',
),
_TmdbRecommendationRowKind.popularMovies => gateway.getPopularMovies(
ready.config,
language: ready.language,
),
_TmdbRecommendationRowKind.popularTv => gateway.getPopularTv(
ready.config,
language: ready.language,
),
_TmdbRecommendationRowKind.airingTodayTv => gateway.getAiringTodayTv(
ready.config,
language: ready.language,
),
_TmdbRecommendationRowKind.topRatedMovies => gateway.getTopRatedMovies(
ready.config,
language: ready.language,
),
};
}
}
class _TmdbDiscoverRowNotifier extends AsyncNotifier<TmdbRecommendationsRes?> {
final TmdbDiscoverKey arg;
_TmdbDiscoverRowNotifier(this.arg);
@override
Future<TmdbRecommendationsRes?> build() async {
final rowKey = _rowKeyForDiscover(arg);
final scope = await ref.watch(_tmdbCacheScopeProvider.future);
if (scope == null) return null;
final cached = await _readDiscoverRowCacheByScope(ref, scope, rowKey);
if (cached != null) {
unawaited(_refreshFromNetwork(arg, rowKey));
return cached;
}
final ready = await ref.watch(_tmdbReadyProvider.future);
if (ready == null) return null;
return _fetchAndCache(arg, ready, rowKey);
}
Future<void> _refreshFromNetwork(TmdbDiscoverKey key, String rowKey) async {
try {
final ready = await ref.read(_tmdbReadyProvider.future);
if (ready == null) return;
final fresh = await _fetchAndCache(key, ready, rowKey);
if (fresh != null) {
state = AsyncValue.data(fresh);
}
} catch (e) {
AppLogger.warn('TMDB', 'refresh discover row $rowKey failed', e);
}
}
Future<TmdbRecommendationsRes?> _fetchAndCache(
TmdbDiscoverKey key,
_TmdbReady ready,
String rowKey,
) async {
final fresh = await ref
.read(tmdbGatewayProvider)
.getDiscover(
ready.config,
key.mediaType,
language: ready.language,
sortBy: key.sortBy,
withGenres: key.withGenres,
voteCountGte: key.voteCountGte,
);
if (fresh != null) {
await _writeDiscoverRowCache(ref, ready, rowKey, fresh);
}
return fresh;
}
}
DiscoverPageCacheScope _cacheScopeForReady(_TmdbReady ready) {
return DiscoverPageCacheScope(
apiBaseUrl: ready.config.apiBaseUrl,
imageBaseUrl: ready.config.imageBaseUrl,
language: ready.language,
);
}
Future<TmdbRecommendationsRes?> _readDiscoverRowCacheByScope(
Ref ref,
DiscoverPageCacheScope scope,
String rowKey,
) async {
final store = await ref.read(discoverPageCacheStoreProvider.future);
final entry = await store.readRow(scope, rowKey);
return entry?.data;
}
Future<void> _writeDiscoverRowCache(
Ref ref,
_TmdbReady ready,
String rowKey,
TmdbRecommendationsRes data,
) async {
final store = await ref.read(discoverPageCacheStoreProvider.future);
await store.writeRow(_cacheScopeForReady(ready), rowKey, data);
}
String _rowKeyForKind(_TmdbRecommendationRowKind kind) {
return switch (kind) {
_TmdbRecommendationRowKind.trendingDay => 'trending:day',
_TmdbRecommendationRowKind.trendingWeek => 'trending:week',
_TmdbRecommendationRowKind.popularMovies => 'popular:movie',
_TmdbRecommendationRowKind.popularTv => 'popular:tv',
_TmdbRecommendationRowKind.airingTodayTv => 'airing_today:tv',
_TmdbRecommendationRowKind.topRatedMovies => 'top_rated:movie',
};
}
String _rowKeyForDiscover(TmdbDiscoverKey key) {
return [
'discover',
key.mediaType,
key.sortBy,
key.withGenres ?? '',
key.voteCountGte ?? '',
].join(':');
}
+121
View File
@@ -0,0 +1,121 @@
part of '../di_providers.dart';
final traktGatewayProvider = Provider<TraktGateway>((ref) => TraktApiClient());
final traktTokenStoreProvider = Provider<TraktTokenStore>(
(ref) => TraktTokenStore(),
);
final traktAuthProvider = Provider<TraktAuth>((ref) {
return TraktAuth(
store: ref.read(traktTokenStoreProvider),
gateway: ref.read(traktGatewayProvider),
);
});
final authedTraktGatewayProvider = Provider<AuthedTraktGateway>((ref) {
return AuthedTraktClient(auth: ref.read(traktAuthProvider));
});
final traktSyncQueueProvider = FutureProvider<TraktPendingSyncQueue>((
ref,
) async {
final dir = await ref.watch(appDataDirProvider.future);
return TraktPendingSyncQueue(
store: JsonTraktSyncJobStore(
file: File('${dir.path}/trakt_pending_sync_queue.json'),
),
sender: ref.read(authedTraktGatewayProvider),
policy: TraktRetryPolicy(),
);
});
final traktSyncServiceProvider = FutureProvider<TraktSyncService>((ref) async {
final queue = await ref.read(traktSyncQueueProvider.future);
return TraktSyncService(
traktGateway: ref.read(authedTraktGatewayProvider),
queue: queue,
);
});
final traktContinueWatchingProvider =
FutureProvider<List<TraktContinueWatchingItem>>((ref) async {
final authed = ref.read(authedTraktGatewayProvider);
if (!await authed.isConnected()) return const [];
final ready = await ref.watch(_tmdbReadyProvider.future);
if (ready == null) return const [];
final List<Map<String, dynamic>> raw;
try {
raw = await authed.fetchPlayback();
} catch (e) {
AppLogger.warn('Trakt', 'fetchPlayback failed', e);
return const [];
}
final entries = raw
.map(TraktPlaybackEntry.fromJson)
.whereType<TraktPlaybackEntry>()
.toList();
entries.sort((a, b) {
final pa = a.pausedAt;
final pb = b.pausedAt;
if (pa == null && pb == null) return 0;
if (pa == null) return 1;
if (pb == null) return -1;
return pb.compareTo(pa);
});
final base = <TraktContinueWatchingItem>[];
final seenKeys = <String>{};
for (final e in entries) {
final item = TraktContinueWatchingItem.fromEntry(e);
if (item == null) continue;
if (!seenKeys.add('${item.mediaType}:${item.tmdbId}')) continue;
base.add(item);
if (base.length >= 20) break;
}
if (base.isEmpty) return const [];
const enrichConcurrency = 5;
final gateway = ref.read(tmdbGatewayProvider);
final enriched = <TraktContinueWatchingItem>[];
for (var i = 0; i < base.length; i += enrichConcurrency) {
final chunk = base.skip(i).take(enrichConcurrency);
final results = await Future.wait(
chunk.map((it) async {
try {
final detail = await gateway.getMediaDetail(
ready.config,
it.tmdbId.toString(),
it.mediaType,
language: ready.language,
);
final localized = detail?.title;
final titled = (localized != null && localized.isNotEmpty)
? it.withTitle(localized)
: it;
final url = TmdbImageUrl.backdrop(
ready.config.imageBaseUrl,
detail?.backdropPath,
size: 'w780',
);
return titled.withBackdrop(url);
} catch (_) {
return it;
}
}),
);
enriched.addAll(results);
}
return enriched;
});