Initial commit
This commit is contained in:
@@ -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>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user