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
+129
View File
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'shared/settings_persistence.dart';
enum HeaderMode { solid, frosted }
enum StartupPage { home, servers, aggregated, discover, settings }
const String kStartupPageKey = 'smplayer.startup_page';
StartupPage parseStartupPage(String? s) => switch (s) {
'servers' => StartupPage.servers,
'aggregated' => StartupPage.aggregated,
'discover' => StartupPage.discover,
'settings' => StartupPage.settings,
_ => StartupPage.home,
};
String startupPageToStr(StartupPage p) => switch (p) {
StartupPage.home => 'home',
StartupPage.servers => 'servers',
StartupPage.aggregated => 'aggregated',
StartupPage.discover => 'discover',
StartupPage.settings => 'settings',
};
String startupPagePath(StartupPage p) => switch (p) {
StartupPage.home => '/',
StartupPage.servers => '/servers',
StartupPage.aggregated => '/aggregated',
StartupPage.discover => '/discover',
StartupPage.settings => '/settings',
};
class AppearanceState {
final ThemeMode themeMode;
final HeaderMode headerMode;
final StartupPage startupPage;
const AppearanceState({
this.themeMode = ThemeMode.system,
this.headerMode = HeaderMode.frosted,
this.startupPage = StartupPage.home,
});
AppearanceState copyWith({
ThemeMode? themeMode,
HeaderMode? headerMode,
StartupPage? startupPage,
}) => AppearanceState(
themeMode: themeMode ?? this.themeMode,
headerMode: headerMode ?? this.headerMode,
startupPage: startupPage ?? this.startupPage,
);
}
class AppearanceNotifier extends AsyncNotifier<AppearanceState>
with SettingsPersistence {
static const _keyMode = 'smplayer.theme_mode';
static const _keyHeader = 'smplayer.header_mode';
@override
Future<AppearanceState> build() async {
final prefs = await getPrefs();
final raw = prefs.getString(_keyMode) ?? 'system';
final rawHeader = prefs.getString(_keyHeader) ?? 'frosted';
return AppearanceState(
themeMode: _parse(raw),
headerMode: _parseHeader(rawHeader),
startupPage: parseStartupPage(prefs.getString(kStartupPageKey)),
);
}
static ThemeMode _parse(String s) => switch (s) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
static String _toStr(ThemeMode m) => switch (m) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
static HeaderMode _parseHeader(String s) => switch (s) {
'solid' => HeaderMode.solid,
'frosted' => HeaderMode.frosted,
_ => HeaderMode.frosted,
};
static String _headerToStr(HeaderMode m) => switch (m) {
HeaderMode.solid => 'solid',
HeaderMode.frosted => 'frosted',
};
Future<void> setThemeMode(ThemeMode mode) async {
final current = state.value ?? const AppearanceState();
state = AsyncValue.data(current.copyWith(themeMode: mode));
await persistField(_keyMode, _toStr(mode));
}
Future<void> setHeaderMode(HeaderMode mode) async {
final current = state.value ?? const AppearanceState();
state = AsyncValue.data(current.copyWith(headerMode: mode));
await persistField(_keyHeader, _headerToStr(mode));
}
Future<void> setStartupPage(StartupPage page) async {
final current = state.value ?? const AppearanceState();
state = AsyncValue.data(current.copyWith(startupPage: page));
await persistField(kStartupPageKey, startupPageToStr(page));
}
}
final initialLocationProvider = Provider<String>((_) => '/');
final appearanceProvider =
AsyncNotifierProvider<AppearanceNotifier, AppearanceState>(
AppearanceNotifier.new,
);
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'shared/settings_persistence.dart';
class RememberedAudioTrack {
final int streamIndex;
final String? language;
final String? codec;
final int? channels;
final String? displayTitle;
final DateTime updatedAt;
const RememberedAudioTrack({
required this.streamIndex,
this.language,
this.codec,
this.channels,
this.displayTitle,
required this.updatedAt,
});
Map<String, dynamic> toJson() => {
'streamIndex': streamIndex,
if (language != null) 'language': language,
if (codec != null) 'codec': codec,
if (channels != null) 'channels': channels,
if (displayTitle != null) 'displayTitle': displayTitle,
'updatedAt': updatedAt.toIso8601String(),
};
factory RememberedAudioTrack.fromJson(Map<String, dynamic> json) {
return RememberedAudioTrack(
streamIndex: json['streamIndex'] as int,
language: json['language'] as String?,
codec: json['codec'] as String?,
channels: json['channels'] as int?,
displayTitle: json['displayTitle'] as String?,
updatedAt:
DateTime.tryParse(json['updatedAt'] as String? ?? '') ??
DateTime.now(),
);
}
}
class AudioSettingsState {
final bool rememberAudioTrack;
final String preferredAudioLanguage;
final bool volumeBoost;
const AudioSettingsState({
this.rememberAudioTrack = false,
this.preferredAudioLanguage = '',
this.volumeBoost = false,
});
AudioSettingsState copyWith({
bool? rememberAudioTrack,
String? preferredAudioLanguage,
bool? volumeBoost,
}) => AudioSettingsState(
rememberAudioTrack: rememberAudioTrack ?? this.rememberAudioTrack,
preferredAudioLanguage:
preferredAudioLanguage ?? this.preferredAudioLanguage,
volumeBoost: volumeBoost ?? this.volumeBoost,
);
}
class AudioSettingsNotifier extends AsyncNotifier<AudioSettingsState>
with SettingsPersistence {
static const _keyRememberTrack = 'smplayer.audio_remember_track';
static const _keyPreferredLang = 'smplayer.audio_preferred_language';
static const _keyVolumeBoost = 'smplayer.audio_volume_boost';
static const _keyLastAudioTracks = 'smplayer.audio_last_tracks';
static const _maxRememberedTracks = 200;
@override
Future<AudioSettingsState> build() async {
final prefs = await getPrefs();
return AudioSettingsState(
rememberAudioTrack: prefs.getBool(_keyRememberTrack) ?? false,
preferredAudioLanguage: prefs.getString(_keyPreferredLang) ?? '',
volumeBoost: prefs.getBool(_keyVolumeBoost) ?? false,
);
}
Future<void> setRememberAudioTrack(bool value) async {
final current = state.value ?? const AudioSettingsState();
state = AsyncValue.data(current.copyWith(rememberAudioTrack: value));
await persistField(_keyRememberTrack, value);
}
Future<void> setPreferredAudioLanguage(String language) async {
final current = state.value ?? const AudioSettingsState();
state = AsyncValue.data(current.copyWith(preferredAudioLanguage: language));
await persistField(_keyPreferredLang, language);
}
Future<void> setVolumeBoost(bool value) async {
final current = state.value ?? const AudioSettingsState();
state = AsyncValue.data(current.copyWith(volumeBoost: value));
await persistField(_keyVolumeBoost, value);
}
Future<void> saveLastAudioTrack(String key, RememberedAudioTrack track) =>
putRemembered(
_keyLastAudioTracks,
key,
track.toJson(),
_maxRememberedTracks,
);
Future<RememberedAudioTrack?> getLastAudioTrack(String key) async {
final entry = await readRemembered(_keyLastAudioTracks, key);
return entry == null ? null : RememberedAudioTrack.fromJson(entry);
}
}
final audioSettingsProvider =
AsyncNotifierProvider<AudioSettingsNotifier, AudioSettingsState>(
AudioSettingsNotifier.new,
);
@@ -0,0 +1,317 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/contracts/danmaku.dart';
import 'shared/settings_persistence.dart';
enum ChineseConvertMode { none, toSimplified, toTraditional }
DanmakuPanelSettings defaultDanmakuPanelSettings({bool? isAndroid}) {
final android = isAndroid ?? Platform.isAndroid;
return DanmakuPanelSettings(
opacity: android ? 0.9 : 1.0,
fontSize: android ? 18 : 20,
strokeWidth: android ? 1.2 : 1.5,
lineHeight: android ? 1.4 : 1.6,
maxRows: android ? 3 : 0,
);
}
class DanmakuSettingsState {
final bool enabled;
final ChineseConvertMode chineseConvert;
final List<DanmakuSource> sources;
final List<String> blockedKeywords;
final bool mergeDuplicates;
final DanmakuPanelSettings panelSettings;
const DanmakuSettingsState({
this.enabled = false,
this.chineseConvert = ChineseConvertMode.none,
this.sources = const [],
this.blockedKeywords = const [],
this.mergeDuplicates = false,
this.panelSettings = const DanmakuPanelSettings(),
});
List<DanmakuSource> get enabledSources =>
sources.where((s) => s.enabled && s.url.trim().isNotEmpty).toList();
String get sourceUrl =>
enabledSources.isNotEmpty ? enabledSources.first.url : '';
DanmakuSettingsState copyWith({
bool? enabled,
ChineseConvertMode? chineseConvert,
List<DanmakuSource>? sources,
List<String>? blockedKeywords,
bool? mergeDuplicates,
DanmakuPanelSettings? panelSettings,
}) => DanmakuSettingsState(
enabled: enabled ?? this.enabled,
chineseConvert: chineseConvert ?? this.chineseConvert,
sources: sources ?? this.sources,
blockedKeywords: blockedKeywords ?? this.blockedKeywords,
mergeDuplicates: mergeDuplicates ?? this.mergeDuplicates,
panelSettings: panelSettings ?? this.panelSettings,
);
}
class DanmakuSettingsNotifier extends AsyncNotifier<DanmakuSettingsState>
with SettingsPersistence {
static const _keyEnabled = 'smplayer.danmaku_enabled';
static const _keyChineseConvert = 'smplayer.danmaku_chinese_convert';
static const _keySourceUrl = 'smplayer.danmaku_source_url';
static const _keySources = 'smplayer.danmaku_sources';
static const _keyBlockedKeywords = 'smplayer.danmaku_blocked_keywords';
static const _keyMergeDuplicates = 'smplayer.danmaku_merge_duplicates';
static const _keyPanelEnabled = 'smplayer.danmaku_panel_enabled';
static const _keyPanelOpacity = 'smplayer.danmaku_panel_opacity';
static const _keyPanelArea = 'smplayer.danmaku_panel_area';
static const _keyPanelFontSize = 'smplayer.danmaku_panel_font_size';
static const _keyPanelSpeed = 'smplayer.danmaku_panel_speed';
static const _keyPanelOffset = 'smplayer.danmaku_panel_offset';
static const _keyPanelShowTop = 'smplayer.danmaku_panel_show_top';
static const _keyPanelShowScroll = 'smplayer.danmaku_panel_show_scroll';
static const _keyPanelShowBottom = 'smplayer.danmaku_panel_show_bottom';
static const _keyPanelColored = 'smplayer.danmaku_panel_colored';
static const _keyPanelStrokeLegacy = 'smplayer.danmaku_panel_stroke';
static const _keyPanelStrokeWidth = 'smplayer.danmaku_panel_stroke_width';
static const _keyPanelBold = 'smplayer.danmaku_panel_bold';
static const _keyPanelFontFamily = 'smplayer.danmaku_panel_font_family';
static const _keyPanelLineHeight = 'smplayer.danmaku_panel_line_height';
static const _keyPanelFixedToScroll =
'smplayer.danmaku_panel_fixed_to_scroll';
static const _keyPanelMaxRows = 'smplayer.danmaku_panel_max_rows';
static const _keyPanelHeatmapEnabled =
'smplayer.danmaku_panel_heatmap_enabled';
@override
Future<DanmakuSettingsState> build() async {
final prefs = await getPrefs();
return DanmakuSettingsState(
enabled: prefs.getBool(_keyEnabled) ?? false,
chineseConvert: _parseConvert(prefs.getString(_keyChineseConvert)),
sources: _parseSources(
prefs.getStringList(_keySources),
legacyUrl: prefs.getString(_keySourceUrl),
),
blockedKeywords: prefs.getStringList(_keyBlockedKeywords) ?? [],
mergeDuplicates: prefs.getBool(_keyMergeDuplicates) ?? false,
panelSettings: _loadPanelSettings(prefs),
);
}
DanmakuPanelSettings _loadPanelSettings(SharedPreferences prefs) {
final defaults = defaultDanmakuPanelSettings();
return DanmakuPanelSettings(
enabled: prefs.getBool(_keyPanelEnabled) ?? defaults.enabled,
opacity: prefs.getDouble(_keyPanelOpacity) ?? defaults.opacity,
area: prefs.getDouble(_keyPanelArea) ?? defaults.area,
fontSize: prefs.getDouble(_keyPanelFontSize) ?? defaults.fontSize,
speed: prefs.getDouble(_keyPanelSpeed) ?? defaults.speed,
offset: prefs.getDouble(_keyPanelOffset) ?? defaults.offset,
showTop: prefs.getBool(_keyPanelShowTop) ?? defaults.showTop,
showScroll: prefs.getBool(_keyPanelShowScroll) ?? defaults.showScroll,
showBottom: prefs.getBool(_keyPanelShowBottom) ?? defaults.showBottom,
colored: prefs.getBool(_keyPanelColored) ?? defaults.colored,
strokeWidth:
prefs.getDouble(_keyPanelStrokeWidth) ??
((prefs.getBool(_keyPanelStrokeLegacy) ?? true)
? defaults.strokeWidth
: 0.0),
bold: prefs.getBool(_keyPanelBold) ?? defaults.bold,
fontFamily: prefs.getString(_keyPanelFontFamily) ?? defaults.fontFamily,
lineHeight: prefs.getDouble(_keyPanelLineHeight) ?? defaults.lineHeight,
fixedToScroll:
prefs.getBool(_keyPanelFixedToScroll) ?? defaults.fixedToScroll,
maxRows: prefs.getInt(_keyPanelMaxRows) ?? defaults.maxRows,
heatmapEnabled:
prefs.getBool(_keyPanelHeatmapEnabled) ?? defaults.heatmapEnabled,
);
}
static ChineseConvertMode _parseConvert(String? s) => switch (s) {
'simplified' => ChineseConvertMode.toSimplified,
'traditional' => ChineseConvertMode.toTraditional,
_ => ChineseConvertMode.none,
};
static String _convertToStr(ChineseConvertMode m) => switch (m) {
ChineseConvertMode.toSimplified => 'simplified',
ChineseConvertMode.toTraditional => 'traditional',
ChineseConvertMode.none => 'none',
};
static List<DanmakuSource> _parseSources(
List<String>? raw, {
required String? legacyUrl,
}) {
final parsed = <DanmakuSource>[];
for (final item in raw ?? const <String>[]) {
try {
final data = jsonDecode(item);
if (data is Map<String, dynamic>) {
final source = DanmakuSource.fromJson(data);
if (source.url.isNotEmpty) parsed.add(source);
}
} catch (_) {
final url = item.trim();
if (url.isNotEmpty) {
parsed.add(
DanmakuSource(id: _sourceIdFromUrl(url), name: '', url: url),
);
}
}
}
if (parsed.isEmpty) {
final url = legacyUrl?.trim() ?? '';
if (url.isNotEmpty) {
parsed.add(
DanmakuSource(id: _sourceIdFromUrl(url), name: '默认源', url: url),
);
} else {
parsed.add(
DanmakuSource(
id: _sourceIdFromUrl('https://api.dandanplay.net'),
name: '弹弹play',
url: 'https://api.dandanplay.net',
),
);
}
}
return _normalizeSources(parsed);
}
static List<DanmakuSource> _normalizeSources(List<DanmakuSource> sources) {
final seen = <String>{};
final result = <DanmakuSource>[];
for (final source in sources) {
final url = source.url.trim();
if (url.isEmpty || seen.contains(url)) continue;
seen.add(url);
result.add(
source.copyWith(
id: source.id.trim().isEmpty ? _sourceIdFromUrl(url) : source.id,
url: url,
),
);
}
return result;
}
static String _sourceIdFromUrl(String url) =>
'src_${url.hashCode.toUnsigned(32).toRadixString(16)}';
Future<void> setEnabled(bool v) async {
final current = state.value ?? const DanmakuSettingsState();
state = AsyncValue.data(current.copyWith(enabled: v));
await persistField(_keyEnabled, v);
}
Future<void> setChineseConvert(ChineseConvertMode mode) async {
final current = state.value ?? const DanmakuSettingsState();
state = AsyncValue.data(current.copyWith(chineseConvert: mode));
await persistField(_keyChineseConvert, _convertToStr(mode));
}
Future<List<DanmakuSource>> setSources(List<DanmakuSource> sources) async {
final current = state.value ?? const DanmakuSettingsState();
final normalized = _normalizeSources(sources);
state = AsyncValue.data(current.copyWith(sources: normalized));
final enabled = normalized.where((s) => s.enabled).toList();
final enabledUrl = enabled.isEmpty ? null : enabled.first.url;
await persistFields({
_keySources: normalized.map((s) => jsonEncode(s.toJson())).toList(),
_keySourceUrl: enabledUrl,
});
return normalized;
}
Future<void> addSource({required String name, required String url}) async {
final current = state.value ?? const DanmakuSettingsState();
final trimmed = url.trim();
if (trimmed.isEmpty) return;
final source = DanmakuSource(
id: _sourceIdFromUrl(trimmed),
name: name.trim(),
url: trimmed,
);
final existing = current.sources.indexWhere((s) => s.url == trimmed);
final next = [...current.sources];
if (existing >= 0) {
next[existing] = source.copyWith(id: next[existing].id);
} else {
next.add(source);
}
await setSources(next);
}
Future<void> removeSource(String id) async {
final current = state.value ?? const DanmakuSettingsState();
await setSources(current.sources.where((s) => s.id != id).toList());
}
Future<void> addBlockedKeyword(String keyword) async {
final current = state.value ?? const DanmakuSettingsState();
if (current.blockedKeywords.contains(keyword)) return;
final updated = [...current.blockedKeywords, keyword];
state = AsyncValue.data(current.copyWith(blockedKeywords: updated));
await persistField(_keyBlockedKeywords, updated);
}
Future<void> removeBlockedKeyword(String keyword) async {
final current = state.value ?? const DanmakuSettingsState();
final updated = current.blockedKeywords.where((k) => k != keyword).toList();
state = AsyncValue.data(current.copyWith(blockedKeywords: updated));
await persistField(_keyBlockedKeywords, updated);
}
Future<void> setMergeDuplicates(bool v) async {
final current = state.value ?? const DanmakuSettingsState();
state = AsyncValue.data(current.copyWith(mergeDuplicates: v));
await persistField(_keyMergeDuplicates, v);
}
Future<void> updatePanelSettings(DanmakuPanelSettings panel) async {
setPanelSettings(panel);
await persistPanelSettings(panel);
}
void setPanelSettings(DanmakuPanelSettings panel) {
final current = state.value ?? const DanmakuSettingsState();
state = AsyncValue.data(current.copyWith(panelSettings: panel));
}
Future<void> persistPanelSettings(DanmakuPanelSettings panel) async {
await persistFields({
_keyPanelEnabled: panel.enabled,
_keyPanelOpacity: panel.opacity,
_keyPanelArea: panel.area,
_keyPanelFontSize: panel.fontSize,
_keyPanelSpeed: panel.speed,
_keyPanelOffset: panel.offset,
_keyPanelShowTop: panel.showTop,
_keyPanelShowScroll: panel.showScroll,
_keyPanelShowBottom: panel.showBottom,
_keyPanelColored: panel.colored,
_keyPanelStrokeWidth: panel.strokeWidth,
_keyPanelBold: panel.bold,
_keyPanelFontFamily: panel.fontFamily,
_keyPanelLineHeight: panel.lineHeight,
_keyPanelFixedToScroll: panel.fixedToScroll,
_keyPanelMaxRows: panel.maxRows,
_keyPanelHeatmapEnabled: panel.heatmapEnabled,
});
}
}
final danmakuSettingsProvider =
AsyncNotifierProvider<DanmakuSettingsNotifier, DanmakuSettingsState>(
DanmakuSettingsNotifier.new,
);
@@ -0,0 +1,75 @@
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@immutable
class DetailPaletteRequest {
DetailPaletteRequest({required this.url, Map<String, String>? headers})
: headers = headers == null
? null
: Map<String, String>.unmodifiable(headers);
final String url;
final Map<String, String>? headers;
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is DetailPaletteRequest &&
other.url == url &&
mapEquals(other.headers, headers);
}
@override
int get hashCode {
final sortedHeaderEntries = headers?.entries.toList(growable: false)
?..sort((left, right) => left.key.compareTo(right.key));
final headersHash = sortedHeaderEntries == null
? null
: Object.hashAll(
sortedHeaderEntries.map(
(entry) => Object.hash(entry.key, entry.value),
),
);
return Object.hash(url, headersHash);
}
}
final detailPaletteProvider =
FutureProvider.family<Color?, DetailPaletteRequest>((ref, request) async {
try {
final sourceProvider = CachedNetworkImageProvider(
request.url,
headers: request.headers,
);
final resizedProvider = ResizeImage.resizeIfNeeded(
100,
null,
sourceProvider,
);
final colorScheme = await ColorScheme.fromImageProvider(
provider: resizedProvider,
brightness: Brightness.dark,
).timeout(const Duration(seconds: 6));
final sourceColor = HSLColor.fromColor(colorScheme.primary);
return sourceColor
.withSaturation(math.min(sourceColor.saturation, 0.3))
.withLightness(0.11)
.toColor();
} catch (_) {
return null;
}
});
Color createDetailNavigationColor(Color backgroundColor) {
final backgroundHsl = HSLColor.fromColor(backgroundColor);
return backgroundHsl
.withSaturation(math.min(backgroundHsl.saturation, 0.26))
.withLightness(0.07)
.toColor();
}
+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;
});
+72
View File
@@ -0,0 +1,72 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../core/contracts/cancellation.dart';
import '../core/contracts/library.dart';
import '../core/contracts/script_widget.dart';
import '../core/contracts/tmdb.dart';
import '../core/contracts/trakt/trakt_playback_entry.dart';
import '../core/domain/ports/authed_trakt_gateway.dart';
import '../core/domain/ports/danmaku_gateway.dart';
import '../core/domain/ports/danmaku_sources_store.dart';
import '../core/domain/ports/emby_gateway.dart';
import '../core/domain/ports/server_repository.dart';
import '../core/domain/ports/server_sync_gateway.dart';
import '../core/domain/ports/session_repository.dart';
import '../core/domain/ports/script_widget_remote_gateway.dart';
import '../core/domain/ports/script_widget_repository.dart';
import '../core/domain/ports/script_widget_runtime.dart';
import '../core/domain/ports/tmdb_gateway.dart';
import '../core/domain/ports/trakt_gateway.dart';
import '../core/domain/usecases/auth_usecases.dart';
import '../core/domain/usecases/backup_usecases.dart';
import '../core/domain/usecases/server_sync_usecases.dart';
import '../core/domain/usecases/library_usecases.dart';
import '../core/domain/usecases/media_usecases.dart';
import '../core/domain/usecases/series_resume_target.dart';
import '../core/domain/usecases/server_usecases.dart';
import '../core/domain/usecases/script_widget/script_widget_service.dart';
import '../core/domain/usecases/usecase_helpers.dart';
import '../core/infra/danmaku_api/dispatching_danmaku_gateway.dart';
import '../core/infra/danmaku_api/danmaku_client.dart';
import '../core/infra/danmaku_api/script_widget_danmaku_gateway.dart';
import '../core/infra/emby_api/emby_api_client.dart';
import '../core/infra/emby_api/emby_headers.dart';
import '../core/infra/emby_api/playback_resolver.dart';
import '../core/infra/storage/device_id_store.dart';
import '../core/infra/storage/discover_page_cache_store.dart';
import '../core/infra/storage/home_page_cache_store.dart';
import '../core/infra/server_sync_api/server_sync_client.dart';
import '../core/infra/storage/json_server_repository.dart';
import '../core/infra/storage/json_session_repository.dart';
import '../core/infra/storage/json_script_widget_repository.dart';
import '../core/infra/storage/prefs_danmaku_sources_store.dart';
import '../core/infra/storage/search_history_store.dart';
import '../core/infra/script_widget/quickjs_widget_runtime.dart';
import '../core/infra/script_widget/script_widget_remote_client.dart';
import '../core/infra/tmdb_api/tmdb_client.dart';
import '../core/infra/trakt_api/authed_trakt_client.dart';
import '../core/infra/trakt_api/trakt_auth.dart';
import '../core/infra/trakt_api/trakt_client.dart';
import '../core/infra/trakt_api/trakt_pending_sync_queue.dart';
import '../core/infra/trakt_api/trakt_retry_policy.dart';
import '../core/infra/trakt_api/trakt_sync_service.dart';
import '../core/infra/trakt_api/trakt_token_store.dart';
import '../features/discover/trakt_continue_watching_item.dart';
import '../shared/mappers/tmdb_image_url.dart';
import '../shared/utils/app_logger.dart';
import '../shared/utils/platform_device_name.dart';
import '../shared/utils/time_bucket.dart';
import 'danmaku_settings_provider.dart';
import 'sm_account_provider.dart';
import 'tmdb_settings_provider.dart';
part 'di/di_core_stores.dart';
part 'di/di_detail_data.dart';
part 'di/di_emby_usecases.dart';
part 'di/di_script_widget.dart';
part 'di/di_tmdb.dart';
part 'di/di_trakt.dart';
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/infra/emby_api/emby_headers.dart';
import '../shared/utils/platform_device_name.dart';
import 'di_providers.dart';
import 'session_provider.dart';
final embyImageHeadersProvider = FutureProvider<Map<String, String>?>((
ref,
) async {
final session = ref.watch(activeSessionProvider);
if (session == null) return null;
final deviceId = await ref.watch(deviceIdProvider.future);
return EmbyRequestHeaders.image(
deviceId: deviceId,
deviceName: kEmbyDeviceName,
token: session.token,
userId: session.userId,
);
});
+95
View File
@@ -0,0 +1,95 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/library.dart';
import '../core/infra/storage/home_page_cache_store.dart';
import 'di_providers.dart';
import 'session_provider.dart';
class HomeBannerNotifier extends AsyncNotifier<List<EmbyRawItem>> {
@override
Future<List<EmbyRawItem>> build() async {
final session = ref.watch(activeSessionProvider);
if (session == null) return const [];
final store = await ref.read(homePageCacheStoreProvider.future);
final cache = await store.readBanner(session.serverId, session.userId);
if (cache != null) {
unawaited(_refreshBannerFromNetwork(session.serverId, session.userId));
return cache.bannerItems;
}
return _fetchAndCacheBanner(session.serverId, session.userId);
}
Future<List<EmbyRawItem>> _fetchAndCacheBanner(
String serverId,
String userId,
) async {
final store = await ref.read(homePageCacheStoreProvider.future);
final uc = await ref.read(homeBannerUseCaseProvider.future);
final items = await uc.execute();
unawaited(
store.writeBanner(
serverId,
userId,
HomeBannerCacheData(
bannerItems: items,
savedAt: DateTime.now().toUtc(),
),
),
);
return items;
}
Future<void> _refreshBannerFromNetwork(String serverId, String userId) async {
try {
final uc = await ref.read(homeBannerUseCaseProvider.future);
final items = await uc.execute();
final s = ref.read(activeSessionProvider);
if (s == null || s.serverId != serverId || s.userId != userId) return;
state = AsyncValue.data(items);
final store = await ref.read(homePageCacheStoreProvider.future);
unawaited(
store.writeBanner(
serverId,
userId,
HomeBannerCacheData(
bannerItems: items,
savedAt: DateTime.now().toUtc(),
),
),
);
} catch (_) {}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final session = ref.read(activeSessionProvider);
if (session == null) return const [];
return _fetchAndCacheBanner(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 _refreshBannerFromNetwork(session.serverId, session.userId);
}
}
final homeBannerProvider =
AsyncNotifierProvider<HomeBannerNotifier, List<EmbyRawItem>>(
HomeBannerNotifier.new,
);
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/legacy.dart';
enum HomePageMode { server, recommend }
final homePageModeProvider = StateProvider<HomePageMode>(
(_) => HomePageMode.server,
);
+224
View File
@@ -0,0 +1,224 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/icon_library.dart';
import '../core/infra/icon_library_api/icon_library_client.dart';
import 'shared/settings_persistence.dart';
enum IconLibraryStatus { idle, loading, loaded, error }
class IconLibraryEntryState {
final String url;
final IconLibrary? library;
final IconLibraryStatus status;
final String? error;
const IconLibraryEntryState({
required this.url,
this.library,
this.status = IconLibraryStatus.idle,
this.error,
});
IconLibraryEntryState copyWith({
IconLibrary? library,
IconLibraryStatus? status,
String? error,
bool clearError = false,
}) => IconLibraryEntryState(
url: url,
library: library ?? this.library,
status: status ?? this.status,
error: clearError ? null : (error ?? this.error),
);
int get iconCount => library?.icons.length ?? 0;
}
class IconLibrarySettingsState {
final List<IconLibraryEntryState> entries;
const IconLibrarySettingsState({this.entries = const []});
IconLibrarySettingsState copyWith({List<IconLibraryEntryState>? entries}) =>
IconLibrarySettingsState(entries: entries ?? this.entries);
int get totalIcons => entries.fold<int>(0, (acc, e) => acc + e.iconCount);
}
class IconLibrarySettingsNotifier
extends AsyncNotifier<IconLibrarySettingsState>
with SettingsPersistence {
static const _keyUrls = 'smplayer.icon_library_urls';
static const _keyCache = 'smplayer.icon_library_cache';
static const _keyInitialized = 'smplayer.icon_library_initialized';
static const defaultUrls = <String>[
'https://emby-icon.vercel.app/TFEL-Emby.json',
'https://raw.githubusercontent.com/xiyuliu509/Player-Icon/refs/heads/master/invisible.json',
'https://raw.githubusercontent.com/ginibond/ginibond/main/Icons/Forward/tubiao.json',
];
late final IconLibraryClient _client;
@override
Future<IconLibrarySettingsState> build() async {
_client = IconLibraryClient();
final prefs = await getPrefs();
final initialized = prefs.getBool(_keyInitialized) ?? false;
List<String> urls = prefs.getStringList(_keyUrls) ?? const [];
if (!initialized && urls.isEmpty) {
urls = List.of(defaultUrls);
await persistFields({_keyUrls: urls, _keyInitialized: true});
} else if (!initialized) {
await persistField(_keyInitialized, true);
}
final cacheRaw = prefs.getString(_keyCache);
final cache = <String, IconLibrary>{};
if (cacheRaw != null && cacheRaw.isNotEmpty) {
try {
final decoded = jsonDecode(cacheRaw);
if (decoded is Map<String, dynamic>) {
for (final e in decoded.entries) {
final value = e.value;
if (value is Map<String, dynamic>) {
cache[e.key] = IconLibrary.parse(e.key, value);
}
}
}
} catch (err) {
debugPrint('[IconLibrary] failed to decode cache: $err');
}
}
final entries = urls
.map(
(u) => IconLibraryEntryState(
url: u,
library: cache[u],
status: cache[u] != null
? IconLibraryStatus.loaded
: IconLibraryStatus.idle,
),
)
.toList();
final initialState = IconLibrarySettingsState(entries: entries);
Future.microtask(() async {
for (final e in entries) {
if (e.library == null) {
await refresh(e.url);
}
}
});
return initialState;
}
Future<void> addUrl(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) return;
final current = state.value ?? const IconLibrarySettingsState();
if (current.entries.any((e) => e.url == trimmed)) return;
final next = [
...current.entries,
IconLibraryEntryState(url: trimmed, status: IconLibraryStatus.loading),
];
state = AsyncValue.data(current.copyWith(entries: next));
await _persistUrls(next);
await refresh(trimmed);
}
Future<void> removeUrl(String url) async {
final current = state.value ?? const IconLibrarySettingsState();
final next = current.entries.where((e) => e.url != url).toList();
if (next.length == current.entries.length) return;
state = AsyncValue.data(current.copyWith(entries: next));
await _persistUrls(next);
await _persistCache(next);
}
Future<void> refresh(String url) async {
final current = state.value;
if (current == null) return;
final idx = current.entries.indexWhere((e) => e.url == url);
if (idx < 0) return;
var entries = List<IconLibraryEntryState>.of(current.entries);
entries[idx] = entries[idx].copyWith(
status: IconLibraryStatus.loading,
clearError: true,
);
state = AsyncValue.data(current.copyWith(entries: entries));
try {
final lib = await _client.fetch(url);
final after = state.value;
if (after == null) return;
final i = after.entries.indexWhere((e) => e.url == url);
if (i < 0) return;
entries = List<IconLibraryEntryState>.of(after.entries);
entries[i] = entries[i].copyWith(
library: lib,
status: IconLibraryStatus.loaded,
clearError: true,
);
state = AsyncValue.data(after.copyWith(entries: entries));
await _persistCache(entries);
} catch (err) {
debugPrint('[IconLibrary] refresh failed for $url: $err');
final after = state.value;
if (after == null) return;
final i = after.entries.indexWhere((e) => e.url == url);
if (i < 0) return;
entries = List<IconLibraryEntryState>.of(after.entries);
entries[i] = entries[i].copyWith(
status: IconLibraryStatus.error,
error: err.toString(),
);
state = AsyncValue.data(after.copyWith(entries: entries));
}
}
Future<void> refreshAll() async {
final current = state.value;
if (current == null) return;
for (final e in current.entries) {
await refresh(e.url);
}
}
Future<void> _persistUrls(List<IconLibraryEntryState> entries) async {
await persistField(_keyUrls, entries.map((e) => e.url).toList());
}
Future<void> _persistCache(List<IconLibraryEntryState> entries) async {
final map = <String, dynamic>{};
for (final e in entries) {
final lib = e.library;
if (lib != null) map[e.url] = lib.toJson();
}
await persistField(_keyCache, jsonEncode(map));
}
}
final iconLibraryProvider =
AsyncNotifierProvider<
IconLibrarySettingsNotifier,
IconLibrarySettingsState
>(IconLibrarySettingsNotifier.new);
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/library.dart';
import '../core/domain/ports/emby_gateway.dart';
import 'di_providers.dart';
import 'session_provider.dart';
final serverItemCountsProvider = FutureProvider.family<ItemCountsRes?, String>((
ref,
serverId,
) async {
final sessions = ref.watch(sessionProvider);
final session = sessions.value?.sessions
.where((s) => s.serverId == serverId)
.firstOrNull;
if (session == null) return null;
final gateway = await ref.read(embyGatewayProvider.future);
final ctx = AuthedRequestContext(
baseUrl: session.serverUrl,
token: session.token,
userId: session.userId,
);
return gateway.getItemCounts(ctx);
});
@@ -0,0 +1,36 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'shared/settings_persistence.dart';
class LastPlayedVersionStore with SettingsPersistence {
static const _key = 'smplayer.last_played_version';
static const _maxRemembered = 500;
String _entryKey(String serverId, String itemId) => '$serverId:$itemId';
Future<void> save({
required String serverId,
required String itemId,
required String mediaSourceId,
}) async {
if (serverId.isEmpty || itemId.isEmpty || mediaSourceId.isEmpty) return;
await putRemembered(_key, _entryKey(serverId, itemId), {
'mediaSourceId': mediaSourceId,
'updatedAt': DateTime.now().toIso8601String(),
}, _maxRemembered);
}
Future<String?> get({
required String serverId,
required String itemId,
}) async {
if (serverId.isEmpty || itemId.isEmpty) return null;
final entry = await readRemembered(_key, _entryKey(serverId, itemId));
return entry?['mediaSourceId'] as String?;
}
}
final lastPlayedVersionStoreProvider = Provider<LastPlayedVersionStore>(
(ref) => LastPlayedVersionStore(),
);
@@ -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,
});
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/library.dart';
import '../core/domain/ports/emby_gateway.dart';
import 'di_providers.dart';
import 'session_provider.dart';
final personItemsProvider = FutureProvider.family<List<EmbyRawItem>, String>((
ref,
embyPersonId,
) async {
if (embyPersonId.isEmpty) return const <EmbyRawItem>[];
final session = ref.watch(activeSessionProvider);
if (session == null) return const <EmbyRawItem>[];
final gateway = await ref.read(embyGatewayProvider.future);
final ctx = AuthedRequestContext(
baseUrl: session.serverUrl,
token: session.token,
userId: session.userId,
);
return gateway.getLibraryItems(
ctx: ctx,
personIds: [embyPersonId],
includeItemTypes: 'Movie,Series',
recursive: true,
sortBy: 'ProductionYear,SortName',
sortOrder: SortOrder.descending,
limit: 200,
fields:
'BasicSyncInfo,PrimaryImageAspectRatio,CommunityRating,ProductionYear,UserData',
);
});
final embyPersonIdByNameProvider = FutureProvider.family<String?, String>((
ref,
name,
) async {
final trimmed = name.trim();
if (trimmed.isEmpty) return null;
final session = ref.watch(activeSessionProvider);
if (session == null) return null;
final gateway = await ref.read(embyGatewayProvider.future);
final ctx = AuthedRequestContext(
baseUrl: session.serverUrl,
token: session.token,
userId: session.userId,
);
final items = await gateway.getLibraryItems(
ctx: ctx,
includeItemTypes: 'Person',
searchTerm: trimmed,
recursive: true,
limit: 5,
fields: 'BasicSyncInfo',
);
if (items.isEmpty) return null;
final lower = trimmed.toLowerCase();
final exact = items.where((e) => e.Name.toLowerCase() == lower);
final match = exact.isNotEmpty ? exact.first : items.first;
return match.Id;
});
final personItemDetailProvider = FutureProvider.family<EmbyRawItem?, String>((
ref,
embyPersonId,
) async {
if (embyPersonId.isEmpty) return null;
final session = ref.watch(activeSessionProvider);
if (session == null) return null;
final gateway = await ref.read(embyGatewayProvider.future);
final ctx = AuthedRequestContext(
baseUrl: session.serverUrl,
token: session.token,
userId: session.userId,
);
try {
return await gateway.getItemDetail(ctx, embyPersonId);
} catch (_) {
return null;
}
});
@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
final playbackActiveProvider = StateProvider<bool>((ref) => false);
class DetailPageActivity extends Notifier<int> {
var _disposed = false;
@override
int build() {
ref.onDispose(() => _disposed = true);
return 0;
}
void enter() {
if (_disposed) return;
state = state + 1;
}
void leave() {
if (_disposed || state <= 0) return;
state = state - 1;
}
}
final detailPageActivityProvider = NotifierProvider<DetailPageActivity, int>(
DetailPageActivity.new,
);
final detailPageActiveProvider = Provider<bool>(
(ref) => ref.watch(detailPageActivityProvider) > 0,
);
@@ -0,0 +1,104 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/domain/usecases/usecase_helpers.dart';
import '../core/infra/emby_api/playback_resolver.dart';
import '../shared/utils/app_logger.dart';
import 'di_providers.dart';
const _tag = 'PlaybackInfoPrefetch';
typedef PlaybackInfoPrefetchKey = ({
String serverId,
String itemId,
String? mediaSourceId,
int startTimeTicks,
});
typedef PlaybackInfoPrefetchPayload = ({
String serverId,
String itemId,
String? mediaSourceId,
int startTimeTicks,
Map<String, dynamic> info,
});
String describePlaybackInfoPrefetchKey(PlaybackInfoPrefetchKey key) {
return 'serverId=${key.serverId} itemId=${key.itemId} '
'mediaSourceId=${key.mediaSourceId ?? '-'} '
'startTimeTicks=${key.startTimeTicks}';
}
final playbackInfoPrefetchProvider = FutureProvider.autoDispose
.family<PlaybackInfoPrefetchPayload?, PlaybackInfoPrefetchKey>((
ref,
key,
) async {
final keepAlive = ref.keepAlive();
Timer? cacheTimer;
var disposed = false;
ref.onDispose(() {
disposed = true;
cacheTimer?.cancel();
});
try {
AppLogger.debug(
_tag,
'fetch start ${describePlaybackInfoPrefetchKey(key)}',
);
final gateway = await ref.read(embyGatewayProvider.future);
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) {
AppLogger.debug(
_tag,
'fetch skipped missing auth ${describePlaybackInfoPrefetchKey(key)}',
);
return null;
}
final info = await gateway.fetchPlaybackInfo(
ctx: ctx,
itemId: key.itemId,
mediaSourceId: key.mediaSourceId,
startTimeTicks: key.startTimeTicks,
isPlayback: true,
deviceProfile: PlaybackResolver.deviceProfile(),
);
if (info.isEmpty) {
AppLogger.debug(
_tag,
'fetch empty ${describePlaybackInfoPrefetchKey(key)}',
);
return null;
}
AppLogger.debug(
_tag,
'fetch success ${describePlaybackInfoPrefetchKey(key)} '
'payloadKeys=${info.length}',
);
return (
serverId: key.serverId,
itemId: key.itemId,
mediaSourceId: key.mediaSourceId,
startTimeTicks: key.startTimeTicks,
info: info,
);
} catch (e) {
AppLogger.debug(
_tag,
'fetch failed ${describePlaybackInfoPrefetchKey(key)}',
e,
);
return null;
} finally {
if (!disposed) {
cacheTimer = Timer(const Duration(minutes: 2), keepAlive.close);
}
}
});
@@ -0,0 +1,41 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'shared/settings_persistence.dart';
enum PlayerEngineOverride {
auto('自动'),
fvp('fvp (libmdk)'),
mpv('mpv (media_kit)'),
media3('Media3 (ExoPlayer+FFmpeg)');
final String label;
const PlayerEngineOverride(this.label);
}
class PlayerEngineOverrideNotifier extends AsyncNotifier<PlayerEngineOverride>
with SettingsPersistence {
static const _key = 'smplayer.player_engine_override';
@override
Future<PlayerEngineOverride> build() async {
final prefs = await getPrefs();
final raw = prefs.getString(_key);
if (raw == null) return PlayerEngineOverride.auto;
try {
return PlayerEngineOverride.values.byName(raw);
} catch (_) {
return PlayerEngineOverride.auto;
}
}
Future<void> setOverride(PlayerEngineOverride value) async {
state = AsyncValue.data(value);
await persistField(_key, value.name);
}
}
final playerEngineOverrideProvider =
AsyncNotifierProvider<PlayerEngineOverrideNotifier, PlayerEngineOverride>(
PlayerEngineOverrideNotifier.new,
);
+241
View File
@@ -0,0 +1,241 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/player_gestures.dart';
import 'shared/settings_persistence.dart';
class PlayerSettingsState {
final String hwdecMode;
final double volume;
final int seekForwardSeconds;
final int seekBackwardSeconds;
final double keyboardHoldLeftSpeed;
final double keyboardHoldRightSpeed;
final GestureAction mouseLeftClickAction;
final GestureAction mouseLeftDoubleClickAction;
final GestureAction mouseRightClickAction;
final GestureAction mouseRightDoubleClickAction;
final bool autoPip;
final bool pipShowEpisodeActions;
final double? androidBrightness;
const PlayerSettingsState({
this.hwdecMode = 'auto-copy',
this.volume = 100.0,
this.seekForwardSeconds = 15,
this.seekBackwardSeconds = 15,
this.keyboardHoldLeftSpeed = 0.5,
this.keyboardHoldRightSpeed = 3.0,
this.mouseLeftClickAction = GestureAction.playPause,
this.mouseLeftDoubleClickAction = GestureAction.fullscreen,
this.mouseRightClickAction = GestureAction.toggleControls,
this.mouseRightDoubleClickAction = GestureAction.none,
this.autoPip = true,
this.pipShowEpisodeActions = true,
this.androidBrightness,
});
PlayerSettingsState copyWith({
String? hwdecMode,
double? volume,
int? seekForwardSeconds,
int? seekBackwardSeconds,
double? keyboardHoldLeftSpeed,
double? keyboardHoldRightSpeed,
GestureAction? mouseLeftClickAction,
GestureAction? mouseLeftDoubleClickAction,
GestureAction? mouseRightClickAction,
GestureAction? mouseRightDoubleClickAction,
bool? autoPip,
bool? pipShowEpisodeActions,
double? androidBrightness,
}) => PlayerSettingsState(
hwdecMode: hwdecMode ?? this.hwdecMode,
volume: volume ?? this.volume,
seekForwardSeconds: seekForwardSeconds ?? this.seekForwardSeconds,
seekBackwardSeconds: seekBackwardSeconds ?? this.seekBackwardSeconds,
keyboardHoldLeftSpeed: keyboardHoldLeftSpeed ?? this.keyboardHoldLeftSpeed,
keyboardHoldRightSpeed:
keyboardHoldRightSpeed ?? this.keyboardHoldRightSpeed,
mouseLeftClickAction: mouseLeftClickAction ?? this.mouseLeftClickAction,
mouseLeftDoubleClickAction:
mouseLeftDoubleClickAction ?? this.mouseLeftDoubleClickAction,
mouseRightClickAction: mouseRightClickAction ?? this.mouseRightClickAction,
mouseRightDoubleClickAction:
mouseRightDoubleClickAction ?? this.mouseRightDoubleClickAction,
autoPip: autoPip ?? this.autoPip,
pipShowEpisodeActions: pipShowEpisodeActions ?? this.pipShowEpisodeActions,
androidBrightness: androidBrightness ?? this.androidBrightness,
);
}
class PlayerSettingsNotifier extends AsyncNotifier<PlayerSettingsState>
with SettingsPersistence {
static const _keyHwdec = 'smplayer.player_hwdec';
static const _legacyKeyEnginePreference = 'smplayer.player_engine_preference';
static const _keyVolume = 'smplayer.player_volume';
static const _keySeekForward = 'smplayer.player_seek_forward';
static const _keySeekBackward = 'smplayer.player_seek_backward';
static const _keyKbdHoldLeft = 'smplayer.gesture_kbd_hold_left_speed';
static const _keyKbdHoldRight = 'smplayer.gesture_kbd_hold_right_speed';
static const _keyMouseLeftClick = 'smplayer.gesture_mouse_left_click';
static const _keyMouseLeftDblClick = 'smplayer.gesture_mouse_left_dbl_click';
static const _keyMouseRightClick = 'smplayer.gesture_mouse_right_click';
static const _keyMouseRightDblClick =
'smplayer.gesture_mouse_right_dbl_click';
static const _keyAutoPip = 'smplayer.player_auto_pip';
static const _keyPipEpisodeActions = 'smplayer.player_pip_episode_actions';
static const _keyAndroidBrightness = 'smplayer.player_android_brightness';
static const _speedAllowed = <double>[
0.5,
0.75,
1.0,
1.25,
1.5,
1.75,
2.0,
2.5,
3.0,
4.0,
8.0,
];
static double _normalizeSpeed(double v, double fallback) {
for (final s in _speedAllowed) {
if ((s - v).abs() < 0.001) return s;
}
return fallback;
}
@override
Future<PlayerSettingsState> build() async {
final prefs = await getPrefs();
if (prefs.containsKey(_keyHwdec)) {
await prefs.remove(_keyHwdec);
}
const hwdec = 'auto-copy';
final volume = prefs.getDouble(_keyVolume) ?? 100.0;
final seekFwd = prefs.getInt(_keySeekForward) ?? 15;
final seekBwd = prefs.getInt(_keySeekBackward) ?? 15;
if (prefs.containsKey(_legacyKeyEnginePreference)) {
await prefs.remove(_legacyKeyEnginePreference);
}
final holdLeft = _normalizeSpeed(
prefs.getDouble(_keyKbdHoldLeft) ?? 0.5,
0.5,
);
final holdRight = _normalizeSpeed(
prefs.getDouble(_keyKbdHoldRight) ?? 3.0,
3.0,
);
final autoPip = prefs.getBool(_keyAutoPip) ?? true;
final pipEpisodeActions = prefs.getBool(_keyPipEpisodeActions) ?? true;
final androidBrightness = prefs
.getDouble(_keyAndroidBrightness)
?.clamp(0.0, 1.0);
return PlayerSettingsState(
hwdecMode: hwdec,
volume: volume.clamp(0.0, 200.0),
seekForwardSeconds: seekFwd.clamp(5, 120),
seekBackwardSeconds: seekBwd.clamp(5, 120),
keyboardHoldLeftSpeed: holdLeft,
keyboardHoldRightSpeed: holdRight,
mouseLeftClickAction: gestureActionFromName(
prefs.getString(_keyMouseLeftClick),
GestureAction.playPause,
),
mouseLeftDoubleClickAction: gestureActionFromName(
prefs.getString(_keyMouseLeftDblClick),
GestureAction.fullscreen,
),
mouseRightClickAction: gestureActionFromName(
prefs.getString(_keyMouseRightClick),
GestureAction.toggleControls,
),
mouseRightDoubleClickAction: gestureActionFromName(
prefs.getString(_keyMouseRightDblClick),
GestureAction.none,
),
autoPip: autoPip,
pipShowEpisodeActions: pipEpisodeActions,
androidBrightness: androidBrightness,
);
}
Future<void> setVolume(double volume) async {
final clamped = volume.clamp(0.0, 200.0);
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(volume: clamped));
await persistField(_keyVolume, clamped);
}
Future<void> setSeekForwardSeconds(int seconds) async {
final clamped = seconds.clamp(5, 120);
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(seekForwardSeconds: clamped));
await persistField(_keySeekForward, clamped);
}
Future<void> setSeekBackwardSeconds(int seconds) async {
final clamped = seconds.clamp(5, 120);
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(seekBackwardSeconds: clamped));
await persistField(_keySeekBackward, clamped);
}
Future<void> setKeyboardHoldLeftSpeed(double v) async {
final normalized = _normalizeSpeed(v, 0.5);
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(
current.copyWith(keyboardHoldLeftSpeed: normalized),
);
await persistField(_keyKbdHoldLeft, normalized);
}
Future<void> setKeyboardHoldRightSpeed(double v) async {
final normalized = _normalizeSpeed(v, 3.0);
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(
current.copyWith(keyboardHoldRightSpeed: normalized),
);
await persistField(_keyKbdHoldRight, normalized);
}
Future<void> setMouseLeftClickAction(GestureAction a) async {
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(mouseLeftClickAction: a));
await persistField(_keyMouseLeftClick, a.name);
}
Future<void> setMouseLeftDoubleClickAction(GestureAction a) async {
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(mouseLeftDoubleClickAction: a));
await persistField(_keyMouseLeftDblClick, a.name);
}
Future<void> setMouseRightClickAction(GestureAction a) async {
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(mouseRightClickAction: a));
await persistField(_keyMouseRightClick, a.name);
}
Future<void> setMouseRightDoubleClickAction(GestureAction a) async {
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(mouseRightDoubleClickAction: a));
await persistField(_keyMouseRightDblClick, a.name);
}
Future<void> setAndroidBrightness(double brightness) async {
final clamped = brightness.clamp(0.0, 1.0);
final current = state.value ?? const PlayerSettingsState();
state = AsyncValue.data(current.copyWith(androidBrightness: clamped));
await persistField(_keyAndroidBrightness, clamped);
}
}
final playerSettingsProvider =
AsyncNotifierProvider<PlayerSettingsNotifier, PlayerSettingsState>(
PlayerSettingsNotifier.new,
);
@@ -0,0 +1,29 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/server.dart';
import 'di_providers.dart';
class ServerConnectivity {
final bool reachable;
final int? latencyMs;
const ServerConnectivity({required this.reachable, this.latencyMs});
}
final serverConnectivityProvider =
FutureProvider.family<ServerConnectivity, String>((ref, baseUrl) async {
final uc = await ref.read(probeServerUseCaseProvider.future);
final sw = Stopwatch()..start();
try {
await uc.execute(EmbyServerProbeReq(baseUrl: baseUrl));
sw.stop();
return ServerConnectivity(
reachable: true,
latencyMs: sw.elapsedMilliseconds,
);
} catch (_) {
sw.stop();
return const ServerConnectivity(reachable: false);
}
});
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/server.dart';
import 'di_providers.dart';
class ServerListNotifier extends AsyncNotifier<List<EmbyServer>> {
@override
Future<List<EmbyServer>> build() async {
final usecase = await ref.watch(listServersUseCaseProvider.future);
return usecase.execute();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final usecase = await ref.read(listServersUseCaseProvider.future);
return usecase.execute();
});
}
Future<EmbyServer> save(EmbyServerSaveReq req) async {
final usecase = await ref.read(saveServerUseCaseProvider.future);
final server = await usecase.execute(req);
await refresh();
return server;
}
Future<void> delete(String id) async {
final usecase = await ref.read(deleteServerUseCaseProvider.future);
await usecase.execute(id);
await refresh();
}
Future<EmbyServer> togglePause(String id) async {
final usecase = await ref.read(togglePauseServerUseCaseProvider.future);
final updated = await usecase.execute(id);
await refresh();
return updated;
}
Future<void> reorder(int oldIndex, int newIndex) async {
final currentServers = state.value;
if (currentServers == null || oldIndex == newIndex) return;
final reorderedServers = List<EmbyServer>.of(currentServers);
reorderedServers.insert(newIndex, reorderedServers.removeAt(oldIndex));
state = AsyncValue.data(reorderedServers);
try {
final usecase = await ref.read(reorderServersUseCaseProvider.future);
await usecase.execute(reorderedServers);
} catch (_) {
await refresh();
rethrow;
}
}
}
final serverListProvider =
AsyncNotifierProvider<ServerListNotifier, List<EmbyServer>>(
ServerListNotifier.new,
);
+190
View File
@@ -0,0 +1,190 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/domain/errors.dart';
import '../shared/utils/app_logger.dart';
import 'danmaku_settings_provider.dart';
import 'di_providers.dart';
import 'server_provider.dart';
import 'session_provider.dart';
import 'sm_account_provider.dart';
enum ServerSyncStatus { success, info, failure, needsLogin, needsVip }
class ServerSyncOutcome {
final ServerSyncStatus status;
final String message;
const ServerSyncOutcome(this.status, this.message);
bool get isError =>
status == ServerSyncStatus.failure ||
status == ServerSyncStatus.needsLogin ||
status == ServerSyncStatus.needsVip;
}
class ServerSyncState {
final bool busy;
final DateTime? lastSyncAt;
final String? lastSyncError;
const ServerSyncState({
this.busy = false,
this.lastSyncAt,
this.lastSyncError,
});
}
class ServerSyncNotifier extends Notifier<ServerSyncState> {
static const _tag = 'ServerSync';
@override
ServerSyncState build() => const ServerSyncState();
Future<ServerSyncOutcome> upload() async {
if (state.busy) {
return const ServerSyncOutcome(ServerSyncStatus.info, '正在同步,请稍候');
}
final access = await _resolveAccess();
if (access.outcome != null) return access.outcome!;
final creds = access.creds!;
state = const ServerSyncState(busy: true);
try {
final uc = await ref.read(uploadServerSyncUseCaseProvider.future);
final r = await uc.execute(
baseUrl: creds.baseUrl,
accessToken: creds.accessToken,
);
final outcome = ServerSyncOutcome(
ServerSyncStatus.success,
'已上传 ${r.serverCount} 个服务器、${r.sessionCount} 个登录态、'
'${r.danmakuSourceCount} 个弹幕源、'
'${r.scriptWidgetCount} 个模块、${r.scriptWidgetSubscriptionCount} 个订阅到云端',
);
state = ServerSyncState(lastSyncAt: DateTime.now());
return outcome;
} on DomainError catch (e) {
state = ServerSyncState(lastSyncError: '上传失败:${e.message}');
return ServerSyncOutcome(ServerSyncStatus.failure, '上传失败:${e.message}');
} catch (e) {
AppLogger.warn(_tag, 'upload failed', e);
state = const ServerSyncState(lastSyncError: '上传失败,请稍后重试');
return const ServerSyncOutcome(ServerSyncStatus.failure, '上传失败,请稍后重试');
}
}
Future<ServerSyncOutcome> pull() async {
if (state.busy) {
return const ServerSyncOutcome(ServerSyncStatus.info, '正在同步,请稍候');
}
final access = await _resolveAccess();
if (access.outcome != null) return access.outcome!;
final creds = access.creds!;
state = const ServerSyncState(busy: true);
try {
final uc = await ref.read(pullServerSyncUseCaseProvider.future);
final r = await uc.execute(
baseUrl: creds.baseUrl,
accessToken: creds.accessToken,
);
if (r.empty) {
return const ServerSyncOutcome(ServerSyncStatus.info, '云端还没有备份');
}
await ref.read(serverListProvider.notifier).refresh();
await ref.read(sessionProvider.notifier).refresh();
ref.invalidate(danmakuSettingsProvider);
ref.invalidate(installedScriptWidgetsProvider);
ref.invalidate(scriptWidgetSubscriptionsProvider);
unawaited(_repairInBackground());
final outcome = ServerSyncOutcome(
ServerSyncStatus.success,
'已从云端恢复 ${r.serverCount} 个服务器、${r.sessionCount} 个登录态、'
'${r.danmakuSourceCount} 个弹幕源、'
'${r.scriptWidgetCount} 个模块、${r.scriptWidgetSubscriptionCount} 个订阅',
);
state = ServerSyncState(lastSyncAt: DateTime.now());
return outcome;
} on DomainError catch (e) {
state = ServerSyncState(lastSyncError: '拉取失败:${e.message}');
return ServerSyncOutcome(ServerSyncStatus.failure, '拉取失败:${e.message}');
} catch (e) {
AppLogger.warn(_tag, 'pull failed', e);
state = const ServerSyncState(lastSyncError: '拉取失败,请稍后重试');
return const ServerSyncOutcome(ServerSyncStatus.failure, '拉取失败,请稍后重试');
}
}
Future<void> _repairInBackground() async {
try {
final uc = await ref.read(repairSessionsUseCaseProvider.future);
final repaired = await uc.execute();
if (repaired > 0) {
await ref.read(sessionProvider.notifier).refresh();
AppLogger.info(_tag, 'repaired $repaired session(s) after pull');
}
} catch (e) {
AppLogger.warn(_tag, 'background repair failed', e);
}
}
Future<({_SyncCredentials? creds, ServerSyncOutcome? outcome})>
_resolveAccess() async {
const needsLogin = (
creds: null,
outcome: ServerSyncOutcome(ServerSyncStatus.needsLogin, '请先登录 sm 账号后再同步'),
);
var account = await ref.read(smAccountProvider.future);
if (!account.canUseDataPlane) return needsLogin;
var token = await ref.read(smAccountProvider.notifier).getAccessToken();
if (token == null || token.isEmpty) return needsLogin;
account = await ref.read(smAccountProvider.future);
if (!account.isVip) {
token = await ref
.read(smAccountProvider.notifier)
.getAccessToken(force: true);
if (token == null || token.isEmpty) return needsLogin;
account = await ref.read(smAccountProvider.future);
}
if (!account.isVip) {
return (
creds: null,
outcome: const ServerSyncOutcome(
ServerSyncStatus.needsVip,
'服务器云同步为 VIP 功能,请开通 VIP 后使用',
),
);
}
return (
creds: _SyncCredentials(baseUrl: account.baseUrl, accessToken: token),
outcome: null,
);
}
}
class _SyncCredentials {
final String baseUrl;
final String accessToken;
const _SyncCredentials({required this.baseUrl, required this.accessToken});
}
final serverSyncProvider =
NotifierProvider<ServerSyncNotifier, ServerSyncState>(
ServerSyncNotifier.new,
);
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/auth.dart';
import 'di_providers.dart';
import 'server_provider.dart';
class SessionNotifier extends AsyncNotifier<SessionListRes> {
@override
Future<SessionListRes> build() async {
final usecase = await ref.watch(listSessionsUseCaseProvider.future);
return usecase.execute();
}
Future<void> refresh() async {
state = await AsyncValue.guard(() async {
final usecase = await ref.read(listSessionsUseCaseProvider.future);
return usecase.execute();
});
}
Future<void> login({
required String serverId,
required String username,
required String password,
}) async {
final usecase = await ref.read(authByNameUseCaseProvider.future);
await usecase.execute(
AuthByNameReq(serverId: serverId, username: username, password: password),
);
await refresh();
ref.invalidate(serverListProvider);
}
Future<void> logout({String? serverId}) async {
final usecase = await ref.read(logoutUseCaseProvider.future);
await usecase.execute(serverId);
await refresh();
}
Future<void> switchSession(String serverId) async {
final usecase = await ref.read(switchSessionUseCaseProvider.future);
await usecase.execute(serverId);
await refresh();
}
}
final sessionProvider = AsyncNotifierProvider<SessionNotifier, SessionListRes>(
SessionNotifier.new,
);
final activeSessionProvider = Provider<AuthedSession?>((ref) {
final state = ref.watch(sessionProvider);
return state.maybeWhen(
data: (data) {
if (data.activeServerId == null) return null;
for (final s in data.sessions) {
if (s.isActive) return s;
}
return null;
},
orElse: () => null,
);
});
@@ -0,0 +1,87 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../shared/utils/lru_eviction.dart';
mixin SettingsPersistence {
SharedPreferences? _prefs;
Future<SharedPreferences> getPrefs() async {
return _prefs ??= await SharedPreferences.getInstance();
}
Future<bool> persistField(String key, dynamic value) async {
try {
final p = await getPrefs();
if (value == null) return p.remove(key);
if (value is bool) return p.setBool(key, value);
if (value is int) return p.setInt(key, value);
if (value is double) return p.setDouble(key, value);
if (value is String) return p.setString(key, value);
if (value is List<String>) return p.setStringList(key, value);
return false;
} catch (e) {
debugPrint('[SettingsPersistence] Failed to persist $key: $e');
return false;
}
}
Future<bool> persistFields(Map<String, dynamic> fields) async {
try {
final p = await getPrefs();
final futures = <Future<bool>>[];
for (final entry in fields.entries) {
final key = entry.key;
final value = entry.value;
if (value == null) {
futures.add(p.remove(key));
} else if (value is bool) {
futures.add(p.setBool(key, value));
} else if (value is int) {
futures.add(p.setInt(key, value));
} else if (value is double) {
futures.add(p.setDouble(key, value));
} else if (value is String) {
futures.add(p.setString(key, value));
} else if (value is List<String>) {
futures.add(p.setStringList(key, value));
}
}
await Future.wait(futures);
return true;
} catch (e) {
debugPrint('[SettingsPersistence] Failed to persist fields: $e');
return false;
}
}
Future<void> putRemembered(
String prefsKey,
String entryKey,
Map<String, dynamic> entryJson,
int maxEntries,
) async {
final p = await getPrefs();
final raw = p.getString(prefsKey);
final map = raw != null
? (jsonDecode(raw) as Map<String, dynamic>)
: <String, dynamic>{};
map[entryKey] = entryJson;
evictOldestEntries(map, maxEntries);
await p.setString(prefsKey, jsonEncode(map));
}
Future<Map<String, dynamic>?> readRemembered(
String prefsKey,
String entryKey,
) async {
final raw = (await getPrefs()).getString(prefsKey);
if (raw == null) return null;
final entry = (jsonDecode(raw) as Map<String, dynamic>)[entryKey];
return entry is Map<String, dynamic> ? entry : null;
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@immutable
class ShellBackdropState {
final String primaryImageUrl;
final List<String> fallbackImageUrls;
final Color accentColor;
const ShellBackdropState({
required this.primaryImageUrl,
this.fallbackImageUrls = const <String>[],
required this.accentColor,
});
}
class ShellBackdropNotifier extends Notifier<ShellBackdropState?> {
@override
ShellBackdropState? build() => null;
void show(ShellBackdropState backdrop) {
state = backdrop;
}
void clear() {
state = null;
}
}
final shellBackdropProvider =
NotifierProvider<ShellBackdropNotifier, ShellBackdropState?>(
ShellBackdropNotifier.new,
);
class ShellLocationNotifier extends Notifier<String> {
@override
String build() => '/';
void set(String loc) {
if (state != loc) state = loc;
}
}
final shellLocationProvider = NotifierProvider<ShellLocationNotifier, String>(
ShellLocationNotifier.new,
);
+432
View File
@@ -0,0 +1,432 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/domain/ports/sm_account_gateway.dart';
import '../core/infra/sm_account_api/sm_account_client.dart';
import 'shared/settings_persistence.dart';
import 'tmdb_settings_provider.dart';
final smAccountGatewayProvider = Provider<SmAccountGateway>(
(ref) => SmAccountClient(),
);
enum SmAuthStatus {
unauthenticated,
restoring,
authenticated,
expired,
degraded,
}
class SmAccountState {
final SmAuthStatus status;
final String email;
final String baseUrl;
final String dataRefreshToken;
final bool isVip;
final int? vipExpiresAt;
final bool vipPermanent;
const SmAccountState({
this.status = SmAuthStatus.unauthenticated,
this.email = '',
this.baseUrl = defaultBaseUrl,
this.dataRefreshToken = '',
this.isVip = false,
this.vipExpiresAt,
this.vipPermanent = false,
});
static const defaultBaseUrl = 'https://your-server.example';
String get tmdbApiBaseUrl => '${baseUrl.replaceAll(RegExp(r'/+$'), '')}/3';
bool get loggedIn => status == SmAuthStatus.authenticated;
bool get canUseDataPlane => status == SmAuthStatus.authenticated;
bool get hasActiveSession =>
status == SmAuthStatus.authenticated || status == SmAuthStatus.restoring;
bool get needsReauth =>
status == SmAuthStatus.expired || status == SmAuthStatus.degraded;
bool get hasVip => status == SmAuthStatus.authenticated && isVip;
SmAccountState copyWith({
SmAuthStatus? status,
String? email,
String? baseUrl,
String? dataRefreshToken,
bool? isVip,
int? vipExpiresAt,
bool? vipPermanent,
bool clearVipExpiresAt = false,
}) => SmAccountState(
status: status ?? this.status,
email: email ?? this.email,
baseUrl: baseUrl ?? this.baseUrl,
dataRefreshToken: dataRefreshToken ?? this.dataRefreshToken,
isVip: isVip ?? this.isVip,
vipExpiresAt: clearVipExpiresAt
? null
: (vipExpiresAt ?? this.vipExpiresAt),
vipPermanent: vipPermanent ?? this.vipPermanent,
);
}
class SmAccountNotifier extends AsyncNotifier<SmAccountState>
with SettingsPersistence {
static const _keyBaseUrl = 'smplayer.sm_account_base_url';
static const _keyEmail = 'smplayer.sm_account_email';
static const _keyCookie = 'smplayer.sm_account_cookie';
static const _keyRefreshToken = 'smplayer.sm_account_refresh_token';
static const _keyVip = 'smplayer.sm_account_vip';
static const _keyVipExpiresAt = 'smplayer.sm_account_vip_expires_at';
static const _keyVipPermanent = 'smplayer.sm_account_vip_permanent';
String? _accessToken;
DateTime? _accessTokenExpiry;
Future<String?>? _refreshInFlight;
@override
Future<SmAccountState> build() async {
final prefs = await getPrefs();
final refreshToken = prefs.getString(_keyRefreshToken)?.trim() ?? '';
final legacyCookie = prefs.getString(_keyCookie)?.trim() ?? '';
final rawBaseUrl = prefs.getString(_keyBaseUrl)?.trim();
final baseUrl = (rawBaseUrl == null || rawBaseUrl.isEmpty)
? SmAccountState.defaultBaseUrl
: rawBaseUrl;
final email = prefs.getString(_keyEmail)?.trim() ?? '';
final isVip = prefs.getBool(_keyVip) ?? false;
final vipExpiresAt = prefs.getInt(_keyVipExpiresAt);
final vipPermanent = prefs.getBool(_keyVipPermanent) ?? false;
if (legacyCookie.isNotEmpty) {
await persistField(_keyCookie, null);
}
if (refreshToken.isNotEmpty) {
Future.microtask(_restoreSession);
return SmAccountState(
status: SmAuthStatus.restoring,
email: email,
baseUrl: baseUrl,
dataRefreshToken: refreshToken,
isVip: isVip,
vipExpiresAt: vipExpiresAt,
vipPermanent: vipPermanent,
);
}
if (legacyCookie.isNotEmpty) {
return SmAccountState(
status: SmAuthStatus.degraded,
email: email,
baseUrl: baseUrl,
);
}
return SmAccountState(
status: SmAuthStatus.unauthenticated,
baseUrl: baseUrl,
);
}
Future<void> _restoreSession() async {
final current = state.value;
if (current == null ||
current.status != SmAuthStatus.restoring ||
current.dataRefreshToken.isEmpty) {
return;
}
final token = await getAccessToken(force: true);
if (token != null) {
return;
}
final after = state.value;
if (after != null &&
after.status == SmAuthStatus.restoring &&
after.dataRefreshToken.isNotEmpty) {
_setStatus(SmAuthStatus.authenticated);
}
}
void _setStatus(SmAuthStatus status) {
final current = state.value;
if (current == null || current.status == status) return;
state = AsyncValue.data(current.copyWith(status: status));
}
Future<String?> getAccessToken({bool force = false}) async {
final current = state.value;
if (current == null || current.dataRefreshToken.isEmpty) return null;
if (!force &&
_accessToken != null &&
_accessTokenExpiry != null &&
_accessTokenExpiry!.isAfter(
DateTime.now().add(const Duration(seconds: 60)),
)) {
return _accessToken;
}
final inFlight = _refreshInFlight;
if (inFlight != null) return inFlight;
final refresh = _refreshAccessToken(current);
_refreshInFlight = refresh;
try {
return await refresh;
} finally {
if (identical(_refreshInFlight, refresh)) {
_refreshInFlight = null;
}
}
}
Future<String?> _refreshAccessToken(SmAccountState current) async {
final result = await ref
.read(smAccountGatewayProvider)
.refreshAccessToken(
baseUrl: current.baseUrl,
refreshToken: current.dataRefreshToken,
);
if (!result.ok) {
_accessToken = null;
_accessTokenExpiry = null;
if (result.invalidRefreshToken) {
await _markExpired();
}
return null;
}
_accessToken = result.accessToken;
_accessTokenExpiry = DateTime.now().add(
Duration(seconds: result.expiresIn > 0 ? result.expiresIn : 900),
);
final latest = state.value ?? current;
final rotatedRefreshToken = result.refreshToken.trim();
final nextRefreshToken =
(rotatedRefreshToken.isNotEmpty &&
rotatedRefreshToken != latest.dataRefreshToken)
? rotatedRefreshToken
: latest.dataRefreshToken;
if (nextRefreshToken != latest.dataRefreshToken) {
await persistField(_keyRefreshToken, nextRefreshToken);
}
if (result.hasVipInfo) {
await persistFields(<String, dynamic>{
_keyVip: result.isVip,
_keyVipExpiresAt: result.vipExpiresAt,
_keyVipPermanent: result.vipPermanent,
});
}
state = AsyncValue.data(
latest.copyWith(
status: SmAuthStatus.authenticated,
dataRefreshToken: nextRefreshToken,
isVip: result.hasVipInfo ? result.isVip : null,
vipExpiresAt: result.hasVipInfo ? result.vipExpiresAt : null,
clearVipExpiresAt: result.hasVipInfo && result.vipExpiresAt == null,
vipPermanent: result.hasVipInfo ? result.vipPermanent : null,
),
);
return _accessToken;
}
Future<SmLoginResult> login(String email, String password) async {
final current = state.value ?? const SmAccountState();
final baseUrl = current.baseUrl.trim().isEmpty
? SmAccountState.defaultBaseUrl
: current.baseUrl.trim();
final result = await ref
.read(smAccountGatewayProvider)
.login(baseUrl: baseUrl, email: email.trim(), password: password);
if (!result.ok) return result;
await _applySession(baseUrl, result);
return result;
}
Future<SmActionResult> registerStart(String email) async {
final baseUrl = _effectiveBaseUrl();
final trimmedEmail = email.trim();
final result = await ref
.read(smAccountGatewayProvider)
.registerStart(baseUrl: baseUrl, email: trimmedEmail);
if (result.ok) {
await persistField(_keyBaseUrl, baseUrl);
}
return result;
}
Future<SmLoginResult> registerVerify(
String email,
String password,
String code,
) async {
final baseUrl = _effectiveBaseUrl();
final result = await ref
.read(smAccountGatewayProvider)
.registerVerify(
baseUrl: baseUrl,
email: email.trim(),
password: password,
code: code.trim(),
);
if (!result.ok) return result;
await _applySession(baseUrl, result);
return result;
}
Future<SmActionResult> resendRegisterCode(String email) async {
return ref
.read(smAccountGatewayProvider)
.resendCode(baseUrl: _effectiveBaseUrl(), email: email.trim());
}
Future<void> logout() async {
final current = state.value ?? const SmAccountState();
_accessToken = null;
_accessTokenExpiry = null;
_refreshInFlight = null;
if (current.dataRefreshToken.isNotEmpty) {
await ref
.read(smAccountGatewayProvider)
.logout(
baseUrl: current.baseUrl,
refreshToken: current.dataRefreshToken,
);
}
await persistFields(<String, dynamic>{
_keyEmail: null,
_keyCookie: null,
_keyRefreshToken: null,
_keyVip: null,
_keyVipExpiresAt: null,
_keyVipPermanent: null,
});
state = AsyncValue.data(
SmAccountState(
status: SmAuthStatus.unauthenticated,
baseUrl: current.baseUrl,
),
);
}
Future<void> _applySession(String baseUrl, SmLoginResult result) async {
_accessToken = result.accessToken.isNotEmpty ? result.accessToken : null;
_accessTokenExpiry = result.expiresIn > 0
? DateTime.now().add(Duration(seconds: result.expiresIn))
: null;
_refreshInFlight = null;
await persistFields(<String, dynamic>{
_keyBaseUrl: baseUrl,
_keyEmail: result.email,
_keyRefreshToken: result.refreshToken,
_keyVip: result.isVip,
_keyVipExpiresAt: result.vipExpiresAt,
_keyVipPermanent: result.vipPermanent,
_keyCookie: null,
});
final current = state.value ?? const SmAccountState();
state = AsyncValue.data(
current.copyWith(
status: SmAuthStatus.authenticated,
email: result.email,
baseUrl: baseUrl,
dataRefreshToken: result.refreshToken,
isVip: result.isVip,
vipExpiresAt: result.vipExpiresAt,
clearVipExpiresAt: result.vipExpiresAt == null,
vipPermanent: result.vipPermanent,
),
);
if (result.isVip) {
await ref.read(tmdbSettingsProvider.notifier).enableSmAccountProxy();
}
}
Future<void> _markExpired() async {
_accessToken = null;
_accessTokenExpiry = null;
_refreshInFlight = null;
await persistFields(<String, dynamic>{
_keyRefreshToken: null,
_keyVip: null,
_keyVipExpiresAt: null,
_keyVipPermanent: null,
});
final current = state.value ?? const SmAccountState();
state = AsyncValue.data(
current.copyWith(
status: SmAuthStatus.expired,
dataRefreshToken: '',
isVip: false,
clearVipExpiresAt: true,
vipPermanent: false,
),
);
}
String _effectiveBaseUrl() {
final current = state.value ?? const SmAccountState();
final v = current.baseUrl.trim();
return v.isEmpty ? SmAccountState.defaultBaseUrl : v;
}
}
final smAccountProvider =
AsyncNotifierProvider<SmAccountNotifier, SmAccountState>(
SmAccountNotifier.new,
);
@@ -0,0 +1,187 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'shared/settings_persistence.dart';
const kLanguageNames = <String, String>{
'chi': '中文',
'zho': '中文',
'eng': '英语',
'jpn': '日语',
'kor': '韩语',
'fre': '法语',
'fra': '法语',
'ger': '德语',
'deu': '德语',
'spa': '西班牙语',
'ita': '意大利语',
'por': '葡萄牙语',
'rus': '俄语',
'ara': '阿拉伯语',
'hin': '印地语',
'tha': '泰语',
'vie': '越南语',
'pol': '波兰语',
'tur': '土耳其语',
'dut': '荷兰语',
'nld': '荷兰语',
'dan': '丹麦语',
'fin': '芬兰语',
'swe': '瑞典语',
'nor': '挪威语',
'nob': '书面挪威语',
'cat': '加泰罗尼亚语',
'cze': '捷克语',
'ces': '捷克语',
'hun': '匈牙利语',
'gre': '希腊语',
'ell': '希腊语',
'heb': '希伯来语',
'ind': '印度尼西亚语',
'may': '马来语',
'msa': '马来语',
'hrv': '克罗地亚语',
'baq': '巴斯克语',
'eus': '巴斯克语',
'glg': '加利西亚语',
'fil': '菲律宾语',
'rum': '罗马尼亚语',
'ron': '罗马尼亚语',
'bul': '保加利亚语',
'ukr': '乌克兰语',
'srp': '塞尔维亚语',
'slk': '斯洛伐克语',
'slv': '斯洛文尼亚语',
'lit': '立陶宛语',
'lav': '拉脱维亚语',
'est': '爱沙尼亚语',
};
class RememberedSubtitleTrack {
final int streamIndex;
final String? language;
final String? title;
final DateTime updatedAt;
const RememberedSubtitleTrack({
required this.streamIndex,
this.language,
this.title,
required this.updatedAt,
});
bool get isNone => streamIndex == -1;
Map<String, dynamic> toJson() => {
'streamIndex': streamIndex,
if (language != null) 'language': language,
if (title != null) 'title': title,
'updatedAt': updatedAt.toIso8601String(),
};
factory RememberedSubtitleTrack.fromJson(Map<String, dynamic> json) {
return RememberedSubtitleTrack(
streamIndex: (json['streamIndex'] as num?)?.toInt() ?? -1,
language: json['language'] as String?,
title: json['title'] as String?,
updatedAt:
DateTime.tryParse(json['updatedAt'] as String? ?? '') ??
DateTime.now(),
);
}
}
class SubtitleStyleSettings {
final int position;
final double fontSize;
final double delay;
final double bgOpacity;
const SubtitleStyleSettings({
this.position = 100,
this.fontSize = 55,
this.delay = 0.0,
this.bgOpacity = 0.0,
});
SubtitleStyleSettings copyWith({
int? position,
double? fontSize,
double? delay,
double? bgOpacity,
}) => SubtitleStyleSettings(
position: position ?? this.position,
fontSize: fontSize ?? this.fontSize,
delay: delay ?? this.delay,
bgOpacity: bgOpacity ?? this.bgOpacity,
);
}
class SubtitleSettingsNotifier extends AsyncNotifier<SubtitleStyleSettings>
with SettingsPersistence {
static const _keyPosition = 'smplayer.subtitle_position';
static const _keyFontSize = 'smplayer.subtitle_font_size';
static const _keyDelay = 'smplayer.subtitle_delay';
static const _keyBgOpacity = 'smplayer.subtitle_bg_opacity';
static const _keyLastTracks = 'smplayer.subtitle_last_tracks_primary';
static const _maxRememberedTracks = 200;
@override
Future<SubtitleStyleSettings> build() async {
final prefs = await getPrefs();
for (final stale in const [
'smplayer.subtitle_default_policy',
'smplayer.subtitle_preferred_language',
'smplayer.subtitle_secondary_position',
'smplayer.subtitle_secondary_font_size',
'smplayer.subtitle_secondary_delay',
'smplayer.subtitle_secondary_bg_opacity',
'smplayer.subtitle_secondary_follow_primary',
'smplayer.subtitle_last_tracks_secondary',
]) {
await prefs.remove(stale);
}
return SubtitleStyleSettings(
position: prefs.getInt(_keyPosition) ?? 100,
fontSize: prefs.getDouble(_keyFontSize) ?? 55,
delay: prefs.getDouble(_keyDelay) ?? 0.0,
bgOpacity: prefs.getDouble(_keyBgOpacity) ?? 0.0,
);
}
Future<void> setSettings(SubtitleStyleSettings settings) async {
state = AsyncValue.data(settings);
await persistFields({
_keyPosition: settings.position,
_keyFontSize: settings.fontSize,
_keyDelay: settings.delay,
_keyBgOpacity: settings.bgOpacity,
});
}
Future<void> saveLastSubtitleTrack(
String key,
RememberedSubtitleTrack? track,
) async {
if (track == null) return;
await putRemembered(
_keyLastTracks,
key,
track.toJson(),
_maxRememberedTracks,
);
}
Future<RememberedSubtitleTrack?> getLastSubtitleTrack(String key) async {
final entry = await readRemembered(_keyLastTracks, key);
return entry == null ? null : RememberedSubtitleTrack.fromJson(entry);
}
}
final subtitleSettingsProvider =
AsyncNotifierProvider<SubtitleSettingsNotifier, SubtitleStyleSettings>(
SubtitleSettingsNotifier.new,
);
+244
View File
@@ -0,0 +1,244 @@
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, void>(
TmdbPrefetchNotifier.new,
);
class TmdbPrefetchNotifier extends Notifier<void> {
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<TmdbMediaKey> _queue = Queue<TmdbMediaKey>();
final Set<TmdbMediaKey> _enqueuedKeys = <TmdbMediaKey>{};
final Map<TmdbMediaKey, int> _failureCount = <TmdbMediaKey, int>{};
bool _disposeHookRegistered = false;
bool _disposed = false;
bool _isProcessing = false;
bool _initialDelayElapsed = false;
bool _initialDelayTimerStarted = false;
bool _lastPlaybackActive = false;
bool _lastDetailActive = false;
String? _lastSessionServerId;
List<EmbyRawItem>? _lastResumeRef;
Map<String, List<EmbyRawItem>>? _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 = <EmbyRawItem>[
...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<EmbyRawItem> items, {bool priority = false}) {
final keys = <TmdbMediaKey>[];
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<void>.delayed(_initialDelay, () {
_initialDelayElapsed = true;
if (_disposed) return;
_scheduleProcessing();
});
}
Future<void> _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<void>.delayed(requeued ? _retryDelay : _requestDelay);
}
}
} finally {
_isProcessing = false;
if (!_disposed &&
_queue.isNotEmpty &&
_canRequestTmdb() &&
!_shouldPause) {
_scheduleProcessing();
}
}
}
bool _canRequestTmdb() {
return ref.read(tmdbSettingsProvider).value?.canRequest ?? false;
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'shared/settings_persistence.dart';
enum TmdbAuthMode { bearer, apiKey }
enum TmdbAccessMode {
official,
smAccount,
}
class TmdbSettingsState {
static const officialApiBaseUrl = 'https://api.themoviedb.org/3';
static const officialImageBaseUrl = 'https://image.tmdb.org/t/p';
static const smAccountApiBaseUrl = 'https://your-server.example';
final bool enabled;
final TmdbAccessMode accessMode;
final TmdbAuthMode authMode;
final String accessToken;
final String apiKey;
final String language;
const TmdbSettingsState({
this.enabled = false,
this.accessMode = TmdbAccessMode.official,
this.authMode = TmdbAuthMode.apiKey,
this.accessToken = '',
this.apiKey = '',
this.language = 'zh-CN',
});
bool get isConfigured => switch (accessMode) {
TmdbAccessMode.smAccount => true,
TmdbAccessMode.official => switch (authMode) {
TmdbAuthMode.bearer => accessToken.isNotEmpty,
TmdbAuthMode.apiKey => apiKey.isNotEmpty,
},
};
bool get canRequest => enabled && isConfigured;
bool canRequestWithAccount({required bool dataPlaneReady}) =>
switch (accessMode) {
TmdbAccessMode.official => canRequest,
TmdbAccessMode.smAccount => enabled && dataPlaneReady,
};
String get effectiveApiBaseUrl => switch (accessMode) {
TmdbAccessMode.smAccount => smAccountApiBaseUrl,
TmdbAccessMode.official => officialApiBaseUrl,
};
String get effectiveImageBaseUrl => officialImageBaseUrl;
String get effectiveAccessToken =>
accessMode == TmdbAccessMode.official && authMode == TmdbAuthMode.bearer
? accessToken
: '';
String get effectiveApiKey =>
accessMode == TmdbAccessMode.official && authMode == TmdbAuthMode.apiKey
? apiKey
: '';
TmdbSettingsState copyWith({
bool? enabled,
TmdbAccessMode? accessMode,
TmdbAuthMode? authMode,
String? accessToken,
String? apiKey,
String? language,
}) => TmdbSettingsState(
enabled: enabled ?? this.enabled,
accessMode: accessMode ?? this.accessMode,
authMode: authMode ?? this.authMode,
accessToken: accessToken ?? this.accessToken,
apiKey: apiKey ?? this.apiKey,
language: language ?? this.language,
);
}
class TmdbSettingsNotifier extends AsyncNotifier<TmdbSettingsState>
with SettingsPersistence {
static const _keyEnabled = 'smplayer.tmdb_enabled';
static const _keyAccessMode = 'smplayer.tmdb_access_mode';
static const _keyAuthMode = 'smplayer.tmdb_auth_mode';
static const _keyAccessToken = 'smplayer.tmdb_access_token';
static const _keyApiKey = 'smplayer.tmdb_api_key';
static const _keyLanguage = 'smplayer.tmdb_language';
@override
Future<TmdbSettingsState> build() async {
final prefs = await getPrefs();
final accessToken = prefs.getString(_keyAccessToken)?.trim() ?? '';
final apiKey = prefs.getString(_keyApiKey)?.trim() ?? '';
return TmdbSettingsState(
enabled: prefs.getBool(_keyEnabled) ?? false,
accessMode: _parseAccessMode(prefs.getString(_keyAccessMode)),
authMode: _parseAuthMode(
prefs.getString(_keyAuthMode),
hasLegacyAccessToken: accessToken.isNotEmpty,
),
accessToken: accessToken,
apiKey: apiKey,
language: prefs.getString(_keyLanguage) ?? 'zh-CN',
);
}
Future<void> setEnabled(bool v) async {
final current = state.value ?? const TmdbSettingsState();
state = AsyncValue.data(current.copyWith(enabled: v));
await persistField(_keyEnabled, v);
}
Future<void> setAccessMode(TmdbAccessMode mode) async {
final current = state.value ?? const TmdbSettingsState();
state = AsyncValue.data(current.copyWith(accessMode: mode));
await persistField(_keyAccessMode, mode.name);
}
Future<void> enableSmAccountProxy() async {
final current = state.value ?? await future;
state = AsyncValue.data(
current.copyWith(enabled: true, accessMode: TmdbAccessMode.smAccount),
);
await persistFields(<String, dynamic>{
_keyEnabled: true,
_keyAccessMode: TmdbAccessMode.smAccount.name,
});
}
Future<void> setAuthMode(TmdbAuthMode mode) async {
final current = state.value ?? const TmdbSettingsState();
state = AsyncValue.data(current.copyWith(authMode: mode));
await persistField(_keyAuthMode, mode.name);
}
Future<void> setAccessToken(String token) async {
final trimmed = token.trim();
final current = state.value ?? const TmdbSettingsState();
state = AsyncValue.data(current.copyWith(accessToken: trimmed));
await persistField(_keyAccessToken, trimmed);
}
Future<void> setApiKey(String key) async {
final trimmed = key.trim();
final current = state.value ?? const TmdbSettingsState();
state = AsyncValue.data(current.copyWith(apiKey: trimmed));
await persistField(_keyApiKey, trimmed);
}
Future<void> setLanguage(String lang) async {
final current = state.value ?? const TmdbSettingsState();
state = AsyncValue.data(current.copyWith(language: lang));
await persistField(_keyLanguage, lang);
}
}
final tmdbSettingsProvider =
AsyncNotifierProvider<TmdbSettingsNotifier, TmdbSettingsState>(
TmdbSettingsNotifier.new,
);
TmdbAccessMode _parseAccessMode(String? value) => TmdbAccessMode.values
.firstWhere((m) => m.name == value, orElse: () => TmdbAccessMode.official);
TmdbAuthMode _parseAuthMode(
String? value, {
bool hasLegacyAccessToken = false,
}) {
if (value == null && hasLegacyAccessToken) {
return TmdbAuthMode.bearer;
}
return TmdbAuthMode.values.firstWhere(
(mode) => mode.name == value,
orElse: () => TmdbAuthMode.apiKey,
);
}
+216
View File
@@ -0,0 +1,216 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import '../core/contracts/trakt/trakt_calendar_entry.dart';
import '../core/domain/ports/tmdb_gateway.dart';
import '../shared/mappers/tmdb_image_url.dart';
import '../shared/utils/app_logger.dart';
import 'di_providers.dart';
import 'sm_account_provider.dart';
import 'tmdb_settings_provider.dart';
const _tag = 'TraktCalendar';
enum TraktCalendarFilter { all, shows, movies }
final traktCalendarFilterProvider = StateProvider<TraktCalendarFilter>(
(ref) => TraktCalendarFilter.all,
);
final traktCalendarProvider = FutureProvider<List<TraktCalendarEntry>>((
ref,
) async {
final authed = ref.read(authedTraktGatewayProvider);
if (!await authed.isConnected()) return const [];
final filter = ref.watch(traktCalendarFilterProvider);
final entries = <TraktCalendarEntry>[];
if (filter != TraktCalendarFilter.movies) {
try {
final raw = await authed.fetchCalendarShows();
entries.addAll(
raw
.map(TraktCalendarEntry.fromShowJson)
.whereType<TraktCalendarEntry>(),
);
} catch (e) {
AppLogger.warn(_tag, 'fetchCalendarShows failed', e);
}
}
if (filter != TraktCalendarFilter.shows) {
try {
final raw = await authed.fetchCalendarMovies();
entries.addAll(
raw
.map(TraktCalendarEntry.fromMovieJson)
.whereType<TraktCalendarEntry>(),
);
} catch (e) {
AppLogger.warn(_tag, 'fetchCalendarMovies failed', e);
}
}
entries.sort((a, b) => a.firstAired.compareTo(b.firstAired));
if (entries.isEmpty) return const [];
final tmdbConfig = await _resolveTmdbConfig(ref);
if (tmdbConfig == null) return entries;
final gateway = ref.read(tmdbGatewayProvider);
final cache = <String, _TmdbPosterResult>{};
const enrichConcurrency = 5;
final enriched = <TraktCalendarEntry>[];
for (var i = 0; i < entries.length; i += enrichConcurrency) {
final chunk = entries.skip(i).take(enrichConcurrency);
final results = await Future.wait(
chunk.map((entry) async {
final id = entry.tmdbId;
if (id == null || id <= 0) return entry;
final cacheKey = '${entry.mediaType}:$id';
try {
final cached = cache[cacheKey] ??= await _fetchPoster(
gateway,
tmdbConfig.config,
tmdbConfig.language,
id.toString(),
entry.mediaType,
);
var result = entry;
if (cached.posterUrl != null) {
result = result.withPoster(cached.posterUrl);
}
if (cached.title != null && cached.title!.isNotEmpty) {
result = result.withTitle(cached.title!);
}
return result;
} catch (_) {
return entry;
}
}),
);
enriched.addAll(results);
}
return enriched;
});
Future<_TmdbReadyConfig?> _resolveTmdbConfig(Ref 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 _TmdbReadyConfig(
config: TmdbRequestConfig(
apiBaseUrl: account.tmdbApiBaseUrl,
imageBaseUrl: TmdbSettingsState.officialImageBaseUrl,
accessToken: token,
apiKey: '',
onUnauthorized: () => notifier.getAccessToken(force: true),
),
language: settings.language,
);
}
return _TmdbReadyConfig(
config: TmdbRequestConfig(
apiBaseUrl: settings.effectiveApiBaseUrl,
imageBaseUrl: settings.effectiveImageBaseUrl,
accessToken: settings.effectiveAccessToken,
apiKey: settings.effectiveApiKey,
),
language: settings.language,
);
}
Future<_TmdbPosterResult> _fetchPoster(
TmdbGateway gateway,
TmdbRequestConfig config,
String language,
String tmdbId,
String mediaType,
) async {
final detail = await gateway.getMediaDetail(
config,
tmdbId,
mediaType,
language: language,
);
final posterUrl = TmdbImageUrl.poster(
config.imageBaseUrl,
detail?.posterPath,
size: 'w342',
);
return _TmdbPosterResult(posterUrl: posterUrl, title: detail?.title);
}
class _TmdbReadyConfig {
final TmdbRequestConfig config;
final String language;
const _TmdbReadyConfig({required this.config, required this.language});
}
class _TmdbPosterResult {
final String? posterUrl;
final String? title;
const _TmdbPosterResult({this.posterUrl, this.title});
}
List<CalendarDateGroup> groupByDate(List<TraktCalendarEntry> entries) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final groups = <String, List<TraktCalendarEntry>>{};
final groupOrder = <String, DateTime>{};
for (final e in entries) {
final local = e.firstAired.toLocal();
final date = DateTime(local.year, local.month, local.day);
final label = _dateLabel(date, today);
(groups[label] ??= []).add(e);
groupOrder.putIfAbsent(label, () => date);
}
final sorted = groups.entries.toList()
..sort((a, b) => groupOrder[a.key]!.compareTo(groupOrder[b.key]!));
return [
for (final e in sorted) CalendarDateGroup(label: e.key, entries: e.value),
];
}
String _dateLabel(DateTime date, DateTime today) {
final diff = date.difference(today).inDays;
if (diff == 0) return '今天';
if (diff == 1) return '明天';
final weekday = _weekdayName(date.weekday);
return '$weekday ${date.month}/${date.day}';
}
String _weekdayName(int weekday) => switch (weekday) {
1 => '周一',
2 => '周二',
3 => '周三',
4 => '周四',
5 => '周五',
6 => '周六',
_ => '周日',
};
class CalendarDateGroup {
final String label;
final List<TraktCalendarEntry> entries;
const CalendarDateGroup({required this.label, required this.entries});
}
@@ -0,0 +1,82 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/utils/app_logger.dart';
import 'di_providers.dart';
class TraktScrobbleStatus {
final DateTime? lastSuccessAt;
final String? lastError;
final int pendingCount;
const TraktScrobbleStatus({
this.lastSuccessAt,
this.lastError,
this.pendingCount = 0,
});
TraktScrobbleStatus copyWith({
DateTime? lastSuccessAt,
String? lastError,
bool clearError = false,
int? pendingCount,
}) {
return TraktScrobbleStatus(
lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt,
lastError: clearError ? null : (lastError ?? this.lastError),
pendingCount: pendingCount ?? this.pendingCount,
);
}
}
class TraktScrobbleStatusNotifier extends Notifier<TraktScrobbleStatus> {
static const _tag = 'TraktStatus';
@override
TraktScrobbleStatus build() => const TraktScrobbleStatus();
void recordSuccess() {
state = state.copyWith(lastSuccessAt: DateTime.now(), clearError: true);
_refreshPending();
}
void recordError(String error) {
state = state.copyWith(lastError: error);
_refreshPending();
}
Future<void> refreshPending() => _refreshPending();
Future<void> retryPending() async {
try {
final queue = await ref.read(traktSyncQueueProvider.future);
await queue.drain();
} catch (e) {
AppLogger.warn(_tag, 'retryPending failed', e);
}
await _refreshPending();
}
Future<void> _refreshPending() async {
try {
final queue = await ref.read(traktSyncQueueProvider.future);
final count = await queue.pendingCount();
state = state.copyWith(pendingCount: count);
} catch (e) {
AppLogger.debug(_tag, 'refreshPending failed', e);
}
}
}
final traktScrobbleStatusProvider =
NotifierProvider<TraktScrobbleStatusNotifier, TraktScrobbleStatus>(
TraktScrobbleStatusNotifier.new,
);
+198
View File
@@ -0,0 +1,198 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../core/contracts/trakt/trakt_token.dart';
import '../core/infra/deep_link/deep_link_service.dart';
import '../core/infra/trakt_api/trakt_credentials.dart';
import '../core/infra/trakt_api/trakt_token_store.dart';
import '../shared/utils/app_logger.dart';
import 'di_providers.dart';
import 'shared/settings_persistence.dart';
const _tag = 'Trakt';
const _connectTimeout = Duration(minutes: 5);
class TraktState {
final bool isConnected;
final String username;
final String accessToken;
final String refreshToken;
final int expiresIn;
final int createdAt;
const TraktState({
this.isConnected = false,
this.username = '',
this.accessToken = '',
this.refreshToken = '',
this.expiresIn = 0,
this.createdAt = 0,
});
TraktState copyWith({
bool? isConnected,
String? username,
String? accessToken,
String? refreshToken,
int? expiresIn,
int? createdAt,
}) => TraktState(
isConnected: isConnected ?? this.isConnected,
username: username ?? this.username,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
expiresIn: expiresIn ?? this.expiresIn,
createdAt: createdAt ?? this.createdAt,
);
}
class TraktConnectException implements Exception {
final String message;
const TraktConnectException(this.message);
@override
String toString() => 'TraktConnectException: $message';
}
class TraktSettingsNotifier extends AsyncNotifier<TraktState>
with SettingsPersistence {
static const _keyAccessToken = TraktTokenStore.keyAccessToken;
static const _keyRefreshToken = TraktTokenStore.keyRefreshToken;
static const _keyExpiresIn = TraktTokenStore.keyExpiresIn;
static const _keyCreatedAt = TraktTokenStore.keyCreatedAt;
static const _keyUsername = 'smplayer.trakt_username';
@override
Future<TraktState> build() async {
ref.read(traktAuthProvider).onCleared = () {
final s = state.value;
if (s != null && s.isConnected) {
state = const AsyncValue.data(TraktState());
}
};
final prefs = await getPrefs();
final accessToken = prefs.getString(_keyAccessToken)?.trim() ?? '';
return TraktState(
isConnected: accessToken.isNotEmpty,
username: prefs.getString(_keyUsername) ?? '',
accessToken: accessToken,
refreshToken: prefs.getString(_keyRefreshToken)?.trim() ?? '',
expiresIn: prefs.getInt(_keyExpiresIn) ?? 0,
createdAt: prefs.getInt(_keyCreatedAt) ?? 0,
);
}
Future<void> connect() async {
final gateway = ref.read(traktGatewayProvider);
final csrfState = _generateState();
final redirectFuture = DeepLinkService.instance.uriStream
.firstWhere((uri) => uri.queryParameters['state'] == csrfState)
.timeout(_connectTimeout);
final authorizeUrl = TraktCredentials.authorizeUrl(csrfState);
final launched = await launchUrl(
authorizeUrl,
mode: LaunchMode.externalApplication,
);
if (!launched) {
throw const TraktConnectException('无法打开浏览器');
}
final Uri redirect;
try {
redirect = await redirectFuture;
} on TimeoutException {
throw const TraktConnectException('连接超时,请重试');
}
final error = redirect.queryParameters['error'];
if (error != null && error.isNotEmpty) {
AppLogger.warn(_tag, 'authorization denied: $error');
throw const TraktConnectException('已取消授权');
}
final returnedState = redirect.queryParameters['state'];
if (returnedState != csrfState) {
AppLogger.warn(_tag, 'state mismatch');
throw const TraktConnectException('授权校验失败');
}
final code = redirect.queryParameters['code'];
if (code == null || code.isEmpty) {
throw const TraktConnectException('未收到授权码');
}
final TraktToken token;
try {
token = await gateway.exchangeCode(code);
} catch (e) {
AppLogger.warn(_tag, 'exchangeCode failed', e);
throw const TraktConnectException('令牌交换失败');
}
await _persistToken(token);
var username = '';
try {
final user = await gateway.getMe(token.accessToken);
username = user.username;
} catch (e) {
AppLogger.warn(_tag, 'getMe failed (non-fatal)', e);
}
await persistField(_keyUsername, username);
state = AsyncValue.data(
TraktState(
isConnected: true,
username: username,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresIn: token.expiresIn,
createdAt: token.createdAt,
),
);
}
Future<void> disconnect() async {
await ref.read(traktAuthProvider).clear();
await persistField(_keyUsername, null);
state = const AsyncValue.data(TraktState());
}
Future<String?> ensureValidToken() {
return ref.read(traktAuthProvider).validAccessToken();
}
Future<void> _persistToken(TraktToken token) async {
await persistFields(<String, dynamic>{
_keyAccessToken: token.accessToken,
_keyRefreshToken: token.refreshToken,
_keyExpiresIn: token.expiresIn,
_keyCreatedAt: token.createdAt,
});
}
static String _generateState() {
final rnd = Random.secure();
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
final traktSettingsProvider =
AsyncNotifierProvider<TraktSettingsNotifier, TraktState>(
TraktSettingsNotifier.new,
);
+293
View File
@@ -0,0 +1,293 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/app_update.dart';
import '../features/update/update_service.dart';
import '../shared/utils/app_logger.dart';
import 'shared/settings_persistence.dart';
enum UpdateStatus {
idle,
checking,
upToDate,
available,
downloading,
readyToInstall,
error,
}
class UpdateState {
final UpdateStatus status;
final AppUpdateInfo? info;
final int received;
final int total;
final double speedBytesPerSec;
final String? error;
final bool silent;
final String? installerPath;
const UpdateState({
this.status = UpdateStatus.idle,
this.info,
this.received = 0,
this.total = 0,
this.speedBytesPerSec = 0,
this.error,
this.silent = false,
this.installerPath,
});
UpdateState copyWith({
UpdateStatus? status,
AppUpdateInfo? info,
int? received,
int? total,
double? speedBytesPerSec,
String? error,
bool? silent,
String? installerPath,
bool clearInfo = false,
bool clearError = false,
bool clearInstallerPath = false,
}) {
return UpdateState(
status: status ?? this.status,
info: clearInfo ? null : info ?? this.info,
received: received ?? this.received,
total: total ?? this.total,
speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec,
error: clearError ? null : error ?? this.error,
silent: silent ?? this.silent,
installerPath: clearInstallerPath
? null
: installerPath ?? this.installerPath,
);
}
}
final updateProvider = NotifierProvider<UpdateNotifier, UpdateState>(
UpdateNotifier.new,
);
class UpdateNotifier extends Notifier<UpdateState> with SettingsPersistence {
static const _tag = 'Update';
static const _lastCheckKey = 'smplayer.update.last_check';
static const _silentThrottle = Duration(hours: 12);
final UpdateService _service;
CancelToken? _cancelToken;
DateTime? _lastSpeedSampleAt;
int _lastSpeedSampleBytes = 0;
UpdateNotifier({UpdateService? service})
: _service = service ?? UpdateService();
@override
UpdateState build() => const UpdateState();
Future<void> silentCheck() async {
final prefs = await getPrefs();
final lastCheck = prefs.getInt(_lastCheckKey);
final now = DateTime.now().millisecondsSinceEpoch;
if (lastCheck != null && now - lastCheck < _silentThrottle.inMilliseconds) {
AppLogger.debug(_tag, 'silent check throttled');
return;
}
try {
await prefs.setInt(_lastCheckKey, now);
state = state.copyWith(
status: UpdateStatus.checking,
silent: true,
received: 0,
total: 0,
clearError: true,
clearInstallerPath: true,
);
final result = await _service.check();
if (result.hasUpdate && result.info != null) {
state = state.copyWith(
status: UpdateStatus.available,
info: result.info,
silent: true,
);
} else {
state = state.copyWith(
status: UpdateStatus.idle,
silent: true,
clearInfo: true,
);
}
} catch (e) {
AppLogger.warn(_tag, 'silent check failed', e);
state = state.copyWith(
status: UpdateStatus.idle,
silent: true,
clearError: true,
);
}
}
Future<void> manualCheck() async {
try {
state = state.copyWith(
status: UpdateStatus.checking,
silent: false,
received: 0,
total: 0,
clearError: true,
clearInstallerPath: true,
);
final result = await _service.check();
await persistField(_lastCheckKey, DateTime.now().millisecondsSinceEpoch);
if (result.hasUpdate && result.info != null) {
state = state.copyWith(
status: UpdateStatus.available,
info: result.info,
silent: false,
);
} else {
state = state.copyWith(
status: UpdateStatus.upToDate,
silent: false,
clearInfo: true,
);
}
} catch (e, stack) {
AppLogger.error(_tag, 'manual check failed', e, stack);
state = state.copyWith(
status: UpdateStatus.error,
error: '检查更新失败,请稍后重试',
silent: false,
);
}
}
Future<void> startDownload() async {
final info = state.info;
final asset = info?.selectedAsset;
if (info == null || asset == null) return;
_cancelToken?.cancel('restart download');
final cancelToken = CancelToken();
_cancelToken = cancelToken;
_lastSpeedSampleAt = null;
_lastSpeedSampleBytes = 0;
try {
state = state.copyWith(
status: UpdateStatus.downloading,
received: 0,
total: asset.size,
speedBytesPerSec: 0,
clearError: true,
clearInstallerPath: true,
);
final file = await _service.download(
asset,
cancelToken: cancelToken,
onReceiveProgress: (received, total) {
if (cancelToken.isCancelled) return;
state = state.copyWith(
status: UpdateStatus.downloading,
received: received,
total: total > 0 ? total : asset.size,
speedBytesPerSec: _sampleSpeed(received),
);
},
);
if (cancelToken.isCancelled) return;
state = state.copyWith(
status: UpdateStatus.readyToInstall,
received: state.total,
speedBytesPerSec: 0,
installerPath: file.path,
clearError: true,
);
} on UpdateVerificationException catch (e, stack) {
AppLogger.error(_tag, 'update verification failed', e, stack);
state = state.copyWith(
status: UpdateStatus.error,
speedBytesPerSec: 0,
error: e.message,
);
return;
} on DioException catch (e, stack) {
if (CancelToken.isCancel(e)) {
state = state.copyWith(
status: UpdateStatus.available,
received: 0,
total: asset.size,
speedBytesPerSec: 0,
clearError: true,
);
return;
}
AppLogger.error(_tag, 'download failed', e, stack);
state = state.copyWith(
status: UpdateStatus.error,
speedBytesPerSec: 0,
error: '下载失败,请稍后重试',
);
} catch (e, stack) {
AppLogger.error(_tag, 'download failed', e, stack);
state = state.copyWith(
status: UpdateStatus.error,
speedBytesPerSec: 0,
error: '下载失败,请稍后重试',
);
} finally {
if (identical(_cancelToken, cancelToken)) {
_cancelToken = null;
}
}
}
double? _sampleSpeed(int received) {
final now = DateTime.now();
final lastAt = _lastSpeedSampleAt;
if (lastAt == null) {
_lastSpeedSampleAt = now;
_lastSpeedSampleBytes = received;
return null;
}
final elapsedMs = now.difference(lastAt).inMilliseconds;
if (elapsedMs < 500) return null;
final delta = received - _lastSpeedSampleBytes;
_lastSpeedSampleAt = now;
_lastSpeedSampleBytes = received;
return delta > 0 ? delta * 1000.0 / elapsedMs : 0.0;
}
void cancel() {
_cancelToken?.cancel('cancelled by user');
_cancelToken = null;
final info = state.info;
state = state.copyWith(
status: info == null ? UpdateStatus.idle : UpdateStatus.available,
received: 0,
total: info?.selectedAsset?.size ?? 0,
speedBytesPerSec: 0,
clearError: true,
);
}
Future<void> install() async {
final path = state.installerPath;
if (path == null || path.isEmpty) return;
try {
await _service.launchInstaller(path);
} catch (e, stack) {
AppLogger.error(_tag, 'launch installer failed', e, stack);
state = state.copyWith(status: UpdateStatus.error, error: '安装程序启动失败');
}
}
}
@@ -0,0 +1,117 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/contracts/library.dart';
import '../core/media/media_range_detector.dart';
import 'shared/settings_persistence.dart';
enum VersionPriority {
dolbyVision('杜比视界优先'),
hdr('HDR 优先'),
sdr('SDR 优先'),
highBitrate('高码率优先'),
lowBitrate('低码率优先'),
highFramerate('高帧率优先'),
largeFile('大文件优先');
final String label;
const VersionPriority(this.label);
}
class VersionPriorityState {
final List<VersionPriority> activePriorities;
const VersionPriorityState({this.activePriorities = const []});
}
class VersionPriorityNotifier extends AsyncNotifier<VersionPriorityState>
with SettingsPersistence {
static const _key = 'smplayer.version_priorities';
@override
Future<VersionPriorityState> build() async {
final prefs = await getPrefs();
final raw = prefs.getStringList(_key);
if (raw == null) return const VersionPriorityState();
final priorities = raw
.map((name) {
try {
return VersionPriority.values.byName(name);
} catch (_) {
return null;
}
})
.whereType<VersionPriority>()
.toList();
return VersionPriorityState(activePriorities: priorities);
}
Future<void> _save(List<VersionPriority> list) async {
state = AsyncValue.data(VersionPriorityState(activePriorities: list));
await persistField(_key, list.map((p) => p.name).toList());
}
Future<void> toggle(VersionPriority p) async {
final current = List<VersionPriority>.from(
state.value?.activePriorities ?? [],
);
current.contains(p) ? current.remove(p) : current.add(p);
await _save(current);
}
Future<void> setPriorities(List<VersionPriority> list) => _save(list);
}
final versionPriorityProvider =
AsyncNotifierProvider<VersionPriorityNotifier, VersionPriorityState>(
VersionPriorityNotifier.new,
);
List<EmbyRawMediaSource> sortMediaSources(
List<EmbyRawMediaSource> sources,
List<VersionPriority> priorities,
) {
if (priorities.isEmpty || sources.length <= 1) return sources;
final sorted = List<EmbyRawMediaSource>.from(sources);
sorted.sort((a, b) => compareMediaSourcesByPriority(a, b, priorities));
return sorted;
}
int compareMediaSourcesByPriority(
EmbyRawMediaSource a,
EmbyRawMediaSource b,
List<VersionPriority> priorities,
) {
for (final p in priorities) {
final cmp = _compareByRule(a, b, p);
if (cmp != 0) return cmp;
}
return 0;
}
int _compareByRule(
EmbyRawMediaSource a,
EmbyRawMediaSource b,
VersionPriority rule,
) {
switch (rule) {
case VersionPriority.dolbyVision:
return _boolDesc(isDolbyVision(a), isDolbyVision(b));
case VersionPriority.hdr:
return _boolDesc(isHdr(a), isHdr(b));
case VersionPriority.sdr:
return _boolDesc(!isHdr(a), !isHdr(b));
case VersionPriority.highBitrate:
return (b.Bitrate ?? 0).compareTo(a.Bitrate ?? 0);
case VersionPriority.lowBitrate:
return (a.Bitrate ?? 0).compareTo(b.Bitrate ?? 0);
case VersionPriority.highFramerate:
return getFrameRate(b).compareTo(getFrameRate(a));
case VersionPriority.largeFile:
return (b.Size ?? 0).compareTo(a.Size ?? 0);
}
}
int _boolDesc(bool a, bool b) {
if (a == b) return 0;
return a ? -1 : 1;
}