Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
class DeviceIdStore {
static const _key = 'smplayer.device_id';
Future<String> getOrCreate() async {
final prefs = await SharedPreferences.getInstance();
final existing = prefs.getString(_key);
if (existing != null && existing.isNotEmpty) return existing;
final id = const Uuid().v4();
await prefs.setString(_key, id);
return id;
}
}
@@ -0,0 +1,92 @@
import 'dart:async';
import 'dart:io';
import '../../../shared/utils/async_lock.dart';
import '../../../shared/utils/url_utils.dart';
import '../../contracts/tmdb.dart';
import 'json_file_cache_io.dart';
class DiscoverPageCacheScope {
final String apiBaseUrl;
final String imageBaseUrl;
final String language;
const DiscoverPageCacheScope({
required this.apiBaseUrl,
required this.imageBaseUrl,
required this.language,
});
String get cacheKey => [
_normalize(apiBaseUrl),
_normalize(imageBaseUrl),
language,
].map(sanitizeCacheKeyComponent).join('_');
static String _normalize(String value) => stripTrailingSlash(value.trim());
}
class DiscoverPageCacheEntry {
final TmdbRecommendationsRes data;
final DateTime savedAt;
const DiscoverPageCacheEntry({required this.data, required this.savedAt});
factory DiscoverPageCacheEntry.fromJson(Map<String, dynamic> json) {
return DiscoverPageCacheEntry(
data: TmdbRecommendationsRes.fromJson(
json['data'] as Map<String, dynamic>,
),
savedAt: DateTime.parse(json['savedAt'] as String),
);
}
Map<String, dynamic> toJson() => {
'data': data.toJson(),
'savedAt': savedAt.toUtc().toIso8601String(),
};
}
class DiscoverPageCacheStore {
final Directory _directory;
final Map<String, AsyncLock> _locks = {};
DiscoverPageCacheStore({required this._directory});
Future<DiscoverPageCacheEntry?> readRow(
DiscoverPageCacheScope scope,
String rowKey,
) async {
return readJsonCacheFile(
_rowFile(scope, rowKey),
DiscoverPageCacheEntry.fromJson,
);
}
Future<void> writeRow(
DiscoverPageCacheScope scope,
String rowKey,
TmdbRecommendationsRes data,
) async {
final lock = _locks.putIfAbsent(_lockKey(scope, rowKey), AsyncLock.new);
await lock.run(() async {
await writeJsonCacheFile(
_rowFile(scope, rowKey),
DiscoverPageCacheEntry(
data: data,
savedAt: DateTime.now().toUtc(),
).toJson(),
);
});
}
File _rowFile(DiscoverPageCacheScope scope, String rowKey) {
final safeRowKey = sanitizeCacheKeyComponent(rowKey);
return File(
'${_directory.path}/discover_page_cache_${scope.cacheKey}_$safeRowKey.json',
);
}
String _lockKey(DiscoverPageCacheScope scope, String rowKey) =>
'${scope.cacheKey}:$rowKey';
}
@@ -0,0 +1,175 @@
import 'dart:async';
import 'dart:io';
import '../../../shared/utils/async_lock.dart';
import '../../contracts/library.dart';
import 'json_file_cache_io.dart';
class HomeOverviewCacheData {
final List<EmbyRawLibrary> libraries;
final List<EmbyRawItem> resumeItems;
final Map<String, List<EmbyRawItem>> latestByLibrary;
final DateTime savedAt;
const HomeOverviewCacheData({
required this.libraries,
required this.resumeItems,
required this.latestByLibrary,
required this.savedAt,
});
factory HomeOverviewCacheData.empty() => HomeOverviewCacheData(
libraries: const [],
resumeItems: const [],
latestByLibrary: const {},
savedAt: DateTime.fromMillisecondsSinceEpoch(0),
);
factory HomeOverviewCacheData.fromJson(Map<String, dynamic> json) {
final libs = (json['libraries'] as List<dynamic>? ?? [])
.map((e) => EmbyRawLibrary.fromJson(e as Map<String, dynamic>))
.toList();
final resume = (json['resumeItems'] as List<dynamic>? ?? [])
.map((e) => EmbyRawItem.fromJson(e as Map<String, dynamic>))
.toList();
final latestRaw = json['latestByLibrary'] as Map<String, dynamic>? ?? {};
final latest = latestRaw.map(
(k, v) => MapEntry(
k,
(v as List<dynamic>)
.map((e) => EmbyRawItem.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return HomeOverviewCacheData(
libraries: libs,
resumeItems: resume,
latestByLibrary: latest,
savedAt: DateTime.parse(json['savedAt'] as String),
);
}
Map<String, dynamic> toJson() => {
'libraries': libraries.map((e) => e.toJson()).toList(),
'resumeItems': resumeItems.map(_itemToCacheJson).toList(),
'latestByLibrary': latestByLibrary.map(
(k, v) => MapEntry(k, v.map(_itemToCacheJson).toList()),
),
'savedAt': savedAt.toUtc().toIso8601String(),
};
HomeOverviewCacheData copyWith({
List<EmbyRawLibrary>? libraries,
List<EmbyRawItem>? resumeItems,
Map<String, List<EmbyRawItem>>? latestByLibrary,
}) => HomeOverviewCacheData(
libraries: libraries ?? this.libraries,
resumeItems: resumeItems ?? this.resumeItems,
latestByLibrary: latestByLibrary ?? this.latestByLibrary,
savedAt: DateTime.now().toUtc(),
);
}
class HomeBannerCacheData {
final List<EmbyRawItem> bannerItems;
final DateTime savedAt;
const HomeBannerCacheData({required this.bannerItems, required this.savedAt});
factory HomeBannerCacheData.fromJson(Map<String, dynamic> json) {
final items = (json['bannerItems'] as List<dynamic>? ?? [])
.map((e) => EmbyRawItem.fromJson(e as Map<String, dynamic>))
.toList();
return HomeBannerCacheData(
bannerItems: items,
savedAt: DateTime.parse(json['savedAt'] as String),
);
}
Map<String, dynamic> toJson() => {
'bannerItems': bannerItems.map(_itemToCacheJson).toList(),
'savedAt': savedAt.toUtc().toIso8601String(),
};
}
Map<String, dynamic> _itemToCacheJson(EmbyRawItem item) => {
...item.extra,
...item.toJson(),
};
class HomePageCacheStore {
final Directory _directory;
final Map<String, AsyncLock> _overviewLocks = {};
final Map<String, AsyncLock> _bannerLocks = {};
HomePageCacheStore({required this._directory});
Future<HomeOverviewCacheData?> readOverview(
String serverId,
String userId,
) async {
final file = _overviewFile(serverId, userId);
return readJsonCacheFile(file, HomeOverviewCacheData.fromJson);
}
Future<HomeBannerCacheData?> readBanner(
String serverId,
String userId,
) async {
final file = _bannerFile(serverId, userId);
return readJsonCacheFile(file, HomeBannerCacheData.fromJson);
}
Future<void> updateOverviewFields(
String serverId,
String userId, {
List<EmbyRawLibrary>? libraries,
List<EmbyRawItem>? resumeItems,
Map<String, List<EmbyRawItem>>? latestByLibrary,
}) async {
final lock = _overviewLocks.putIfAbsent(
_cacheKey(serverId, userId),
AsyncLock.new,
);
await lock.run(() async {
final file = _overviewFile(serverId, userId);
final existing =
await readJsonCacheFile(file, HomeOverviewCacheData.fromJson) ??
HomeOverviewCacheData.empty();
final updated = existing.copyWith(
libraries: libraries,
resumeItems: resumeItems,
latestByLibrary: latestByLibrary,
);
await writeJsonCacheFile(file, updated.toJson());
});
}
Future<void> writeBanner(
String serverId,
String userId,
HomeBannerCacheData data,
) async {
final lock = _bannerLocks.putIfAbsent(
_cacheKey(serverId, userId),
AsyncLock.new,
);
await lock.run(() async {
final file = _bannerFile(serverId, userId);
await writeJsonCacheFile(file, data.toJson());
});
}
String _cacheKey(String serverId, String userId) =>
'${sanitizeCacheKeyComponent(serverId)}_'
'${sanitizeCacheKeyComponent(userId)}';
File _overviewFile(String serverId, String userId) => File(
'${_directory.path}/home_overview_cache_${_cacheKey(serverId, userId)}.json',
);
File _bannerFile(String serverId, String userId) => File(
'${_directory.path}/home_banner_cache_${_cacheKey(serverId, userId)}.json',
);
}
@@ -0,0 +1,33 @@
import 'dart:convert';
import 'dart:io';
final _cacheKeyUnsafeChars = RegExp(r'[^a-zA-Z0-9]');
String sanitizeCacheKeyComponent(String value) =>
value.replaceAll(_cacheKeyUnsafeChars, '_');
Future<T?> readJsonCacheFile<T>(
File file,
T Function(Map<String, dynamic>) fromJson,
) async {
try {
if (!await file.exists()) return null;
final raw = await file.readAsString();
if (raw.trim().isEmpty) return null;
return fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (_) {
return null;
}
}
Future<void> writeJsonCacheFile(File file, Map<String, dynamic> json) async {
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(json),
flush: true,
);
}
@@ -0,0 +1,179 @@
import 'dart:convert';
import 'dart:io';
import '../../../shared/utils/async_lock.dart';
import '../../contracts/script_widget.dart';
import '../../domain/ports/script_widget_repository.dart';
class JsonScriptWidgetRepository implements ScriptWidgetRepository {
final File file;
final AsyncLock _lock = AsyncLock();
JsonScriptWidgetRepository({required this.file});
Future<_ScriptWidgetRepositoryState> _read() async {
if (!await file.exists()) return const _ScriptWidgetRepositoryState();
final rawJson = await file.readAsString();
if (rawJson.trim().isEmpty) return const _ScriptWidgetRepositoryState();
try {
final decoded = jsonDecode(rawJson);
if (decoded is! Map<String, dynamic>) {
return const _ScriptWidgetRepositoryState();
}
return _ScriptWidgetRepositoryState.fromJson(decoded);
} catch (_) {
return const _ScriptWidgetRepositoryState();
}
}
Future<void> _write(_ScriptWidgetRepositoryState state) async {
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(state.toJson()),
flush: true,
);
}
@override
Future<List<InstalledScriptWidget>> listWidgets() => _lock.run(() async {
final state = await _read();
final widgets = List<InstalledScriptWidget>.from(state.widgets);
widgets.sort(
(left, right) => left.manifest.title.compareTo(right.manifest.title),
);
return widgets;
});
@override
Future<InstalledScriptWidget?> findWidget(String widgetId) async {
final widgets = await listWidgets();
for (final widget in widgets) {
if (widget.manifest.id == widgetId) return widget;
}
return null;
}
@override
Future<void> upsertWidget(InstalledScriptWidget widget) =>
_lock.run(() async {
final state = await _read();
final widgets = List<InstalledScriptWidget>.from(state.widgets);
final existingIndex = widgets.indexWhere(
(candidate) => candidate.manifest.id == widget.manifest.id,
);
if (existingIndex < 0) {
widgets.add(widget);
} else {
widgets[existingIndex] = widget;
}
await _write(state.copyWith(widgets: widgets));
});
@override
Future<void> deleteWidget(String widgetId) => _lock.run(() async {
final state = await _read();
final widgets = List<InstalledScriptWidget>.from(state.widgets)
..removeWhere((widget) => widget.manifest.id == widgetId);
await _write(state.copyWith(widgets: widgets));
});
@override
Future<List<ScriptWidgetSubscription>> listSubscriptions() =>
_lock.run(() async {
final state = await _read();
return List<ScriptWidgetSubscription>.unmodifiable(state.subscriptions);
});
@override
Future<void> upsertSubscription(ScriptWidgetSubscription subscription) =>
_lock.run(() async {
final state = await _read();
final subscriptions = List<ScriptWidgetSubscription>.from(
state.subscriptions,
);
final existingIndex = subscriptions.indexWhere(
(candidate) => candidate.url == subscription.url,
);
if (existingIndex < 0) {
subscriptions.add(subscription);
} else {
subscriptions[existingIndex] = subscription;
}
await _write(state.copyWith(subscriptions: subscriptions));
});
@override
Future<void> replaceAllWidgets(List<InstalledScriptWidget> widgets) =>
_lock.run(() async {
final state = await _read();
await _write(
state.copyWith(widgets: List<InstalledScriptWidget>.from(widgets)),
);
});
@override
Future<void> replaceAllSubscriptions(
List<ScriptWidgetSubscription> subscriptions,
) => _lock.run(() async {
final state = await _read();
await _write(
state.copyWith(
subscriptions: List<ScriptWidgetSubscription>.from(subscriptions),
),
);
});
}
class _ScriptWidgetRepositoryState {
final List<InstalledScriptWidget> widgets;
final List<ScriptWidgetSubscription> subscriptions;
const _ScriptWidgetRepositoryState({
this.widgets = const <InstalledScriptWidget>[],
this.subscriptions = const <ScriptWidgetSubscription>[],
});
factory _ScriptWidgetRepositoryState.fromJson(Map<String, dynamic> json) {
return _ScriptWidgetRepositoryState(
widgets: _decodeList(json['widgets'], InstalledScriptWidget.fromJson),
subscriptions: _decodeList(
json['subscriptions'],
ScriptWidgetSubscription.fromJson,
),
);
}
_ScriptWidgetRepositoryState copyWith({
List<InstalledScriptWidget>? widgets,
List<ScriptWidgetSubscription>? subscriptions,
}) {
return _ScriptWidgetRepositoryState(
widgets: widgets ?? this.widgets,
subscriptions: subscriptions ?? this.subscriptions,
);
}
Map<String, dynamic> toJson() => <String, dynamic>{
'widgets': widgets.map((widget) => widget.toJson()).toList(),
'subscriptions': subscriptions
.map((subscription) => subscription.toJson())
.toList(),
};
}
List<T> _decodeList<T>(
Object? rawValue,
T Function(Map<String, dynamic>) fromJson,
) {
if (rawValue is! List) return <T>[];
final decodedItems = <T>[];
for (final rawItem in rawValue) {
if (rawItem is! Map) continue;
try {
decodedItems.add(fromJson(Map<String, dynamic>.from(rawItem)));
} catch (_) {}
}
return decodedItems;
}
@@ -0,0 +1,119 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../../../shared/utils/async_lock.dart';
import '../../../shared/utils/url_utils.dart';
import '../../contracts/server.dart';
import '../../domain/ports/server_repository.dart';
class JsonServerRepository implements ServerRepository {
final File _file;
final _lock = AsyncLock();
JsonServerRepository({required this._file});
Future<List<EmbyServer>> _read() async {
if (!await _file.exists()) return <EmbyServer>[];
final raw = await _file.readAsString();
if (raw.trim().isEmpty) return <EmbyServer>[];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => EmbyServer.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
return <EmbyServer>[];
}
}
Future<void> _write(List<EmbyServer> servers) async {
if (!await _file.parent.exists()) {
await _file.parent.create(recursive: true);
}
final json = servers.map((e) => e.toJson()).toList();
await _file.writeAsString(
const JsonEncoder.withIndent(' ').convert(json),
flush: true,
);
}
@override
Future<List<EmbyServer>> list() => _lock.run(_read);
@override
Future<EmbyServer?> findById(String id) async {
final servers = await list();
for (final s in servers) {
if (s.id == id) return s;
}
return null;
}
@override
Future<EmbyServer?> findByBaseUrl(String baseUrl) async {
final servers = await list();
final normalized = stripTrailingSlash(baseUrl.toLowerCase());
for (final s in servers) {
if (stripTrailingSlash(s.baseUrl.toLowerCase()) == normalized) {
return s;
}
}
return null;
}
@override
Future<EmbyServer> create(EmbyServer server) async {
return _lock.run(() async {
final list = await _read();
list.add(server);
await _write(list);
return server;
});
}
@override
Future<EmbyServer> update(EmbyServer server) async {
return _lock.run(() async {
final list = await _read();
final idx = list.indexWhere((e) => e.id == server.id);
if (idx < 0) {
list.add(server);
} else {
list[idx] = server;
}
await _write(list);
return server;
});
}
@override
Future<void> deleteById(String id) async {
await _lock.run(() async {
final list = await _read();
list.removeWhere((e) => e.id == id);
await _write(list);
});
}
@override
Future<void> replaceAll(List<EmbyServer> servers) async {
await _lock.run(() async {
await _write(List<EmbyServer>.from(servers));
});
}
@override
Future<void> touchConnectedAt(String id, String updatedAt) async {
await _lock.run(() async {
final list = await _read();
final idx = list.indexWhere((e) => e.id == id);
if (idx < 0) return;
list[idx] = list[idx].copyWith(
lastConnectedAt: updatedAt,
updatedAt: updatedAt,
);
await _write(list);
});
}
}
@@ -0,0 +1,161 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../../../shared/utils/async_lock.dart';
import '../../contracts/auth.dart';
import '../../domain/ports/session_repository.dart';
class _SessionStoreData {
List<SessionData> sessions;
String? activeServerId;
_SessionStoreData({required this.sessions, this.activeServerId});
factory _SessionStoreData.empty() =>
_SessionStoreData(sessions: <SessionData>[]);
factory _SessionStoreData.fromJson(Map<String, dynamic> json) =>
_SessionStoreData(
sessions: ((json['sessions'] as List<dynamic>?) ?? <dynamic>[])
.map((e) => SessionData.fromJson(e as Map<String, dynamic>))
.toList(),
activeServerId: json['activeServerId'] as String?,
);
Map<String, dynamic> toJson() => {
'sessions': sessions.map((e) => e.toJson()).toList(),
'activeServerId': activeServerId,
};
}
class JsonSessionRepository implements SessionRepository {
final File _file;
final _lock = AsyncLock();
JsonSessionRepository({required this._file});
Future<_SessionStoreData> _read() async {
if (!await _file.exists()) return _SessionStoreData.empty();
final raw = await _file.readAsString();
if (raw.trim().isEmpty) return _SessionStoreData.empty();
try {
return _SessionStoreData.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
return _SessionStoreData.empty();
}
}
Future<void> _write(_SessionStoreData data) async {
if (!await _file.parent.exists()) {
await _file.parent.create(recursive: true);
}
await _file.writeAsString(
const JsonEncoder.withIndent(' ').convert(data.toJson()),
flush: true,
);
}
@override
Future<SessionData> set(SessionData session) async {
return _lock.run(() async {
final data = await _read();
data.sessions.removeWhere((e) => e.serverId == session.serverId);
data.sessions.add(session);
data.activeServerId = session.serverId;
await _write(data);
return session;
});
}
@override
Future<void> setMany(List<SessionData> sessions) async {
if (sessions.isEmpty) return;
await _lock.run(() async {
final data = await _read();
for (final session in sessions) {
data.sessions.removeWhere((e) => e.serverId == session.serverId);
data.sessions.add(session);
}
await _write(data);
});
}
@override
Future<void> replaceAll(
List<SessionData> sessions, {
String? activeServerId,
}) async {
await _lock.run(() async {
await _write(
_SessionStoreData(
sessions: List<SessionData>.from(sessions),
activeServerId: activeServerId,
),
);
});
}
@override
Future<SessionData?> getByServerId(String serverId) async {
final data = await _lock.run(_read);
for (final s in data.sessions) {
if (s.serverId == serverId) return s;
}
return null;
}
@override
Future<SessionData?> getActive() async {
final data = await _lock.run(_read);
if (data.activeServerId == null) {
return data.sessions.isEmpty ? null : data.sessions.first;
}
for (final s in data.sessions) {
if (s.serverId == data.activeServerId) return s;
}
return data.sessions.isEmpty ? null : data.sessions.first;
}
@override
Future<String?> getActiveServerId() async {
final data = await _lock.run(_read);
return data.activeServerId;
}
@override
Future<List<SessionData>> listAll() async {
final data = await _lock.run(_read);
return List<SessionData>.from(data.sessions);
}
@override
Future<void> setActive(String serverId) async {
await _lock.run(() async {
final data = await _read();
data.activeServerId = serverId;
await _write(data);
});
}
@override
Future<void> clear([String? serverId]) async {
await _lock.run(() async {
final data = await _read();
if (serverId == null) {
data.sessions.clear();
data.activeServerId = null;
} else {
data.sessions.removeWhere((e) => e.serverId == serverId);
if (data.activeServerId == serverId) {
data.activeServerId = data.sessions.isEmpty
? null
: data.sessions.first.serverId;
}
}
await _write(data);
});
}
}
@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../contracts/danmaku.dart';
import '../../domain/ports/danmaku_sources_store.dart';
class PrefsDanmakuSourcesStore implements DanmakuSourcesStore {
static const _keySources = 'smplayer.danmaku_sources';
static const _keySourceUrl = 'smplayer.danmaku_source_url';
@override
Future<List<DanmakuSource>> list() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_keySources);
if (raw == null) return const [];
final out = <DanmakuSource>[];
for (final item in raw) {
try {
final data = jsonDecode(item);
if (data is Map<String, dynamic>) {
final source = DanmakuSource.fromJson(data);
if (source.url.isNotEmpty) out.add(source);
}
} catch (_) {}
}
return out;
}
@override
Future<void> replaceAll(List<DanmakuSource> sources) async {
final prefs = await SharedPreferences.getInstance();
final serialized = sources
.where((s) => s.url.trim().isNotEmpty)
.map((s) => jsonEncode(s.toJson()))
.toList();
await prefs.setStringList(_keySources, serialized);
String? enabledFirst;
for (final s in sources) {
if (s.enabled && s.url.trim().isNotEmpty) {
enabledFirst = s.url;
break;
}
}
if (enabledFirst == null) {
await prefs.remove(_keySourceUrl);
} else {
await prefs.setString(_keySourceUrl, enabledFirst);
}
}
}
@@ -0,0 +1,67 @@
import 'dart:async';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../shared/utils/async_lock.dart';
class SearchHistoryStore {
static const _key = 'smplayer.search_history';
static const int _maxItems = 30;
final AsyncLock _lock = AsyncLock();
Future<List<String>> list() async {
final prefs = await SharedPreferences.getInstance();
return _read(prefs);
}
Future<void> add(String keyword) {
final trimmed = keyword.trim();
if (trimmed.isEmpty) return Future<void>.value();
return _lock.run(() async {
final prefs = await SharedPreferences.getInstance();
final history = await _read(prefs);
history
..remove(trimmed)
..insert(0, trimmed);
if (history.length > _maxItems) {
history.removeRange(_maxItems, history.length);
}
await prefs.setStringList(_key, history);
});
}
Future<void> remove(String keyword) {
return _lock.run(() async {
final prefs = await SharedPreferences.getInstance();
final history = await _read(prefs)
..remove(keyword);
await prefs.setStringList(_key, history);
});
}
Future<void> clear() {
return _lock.run(() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
});
}
Future<List<String>> _read(SharedPreferences prefs) async {
final raw = prefs.get(_key);
if (raw is List) return raw.whereType<String>().toList();
if (raw is! String || raw.isEmpty) return <String>[];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return <String>[];
final history = decoded.whereType<String>().toList();
await prefs.setStringList(_key, history);
return history;
} catch (_) {
return <String>[];
}
}
}