Initial commit
This commit is contained in:
@@ -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,
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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?>{},
|
||||
);
|
||||
});
|
||||
@@ -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(':');
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user