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,226 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import '../../contracts/danmaku.dart';
import '../../domain/ports/danmaku_gateway.dart';
import '../emby_api/emby_headers.dart';
import '../../../shared/utils/app_logger.dart';
const _tag = 'Danmaku';
String buildDanmakuFileName({
required String name,
required String? type,
required String? seriesName,
required int? season,
required int? episode,
}) {
if (type == 'Episode' && seriesName != null && seriesName.isNotEmpty) {
if (season != null && episode != null) {
return '$seriesName S${season}E$episode';
}
AppLogger.warn(
_tag,
'buildDanmakuFileName: missing index fields '
'(season=$season, episode=$episode) for "$seriesName / $name"; '
'fallback to raw name',
);
}
return name;
}
class DanmakuApiClient implements DanmakuGateway {
DanmakuApiClient({Dio? dio}) : _dio = dio ?? _buildDefaultDio();
final Dio _dio;
static Dio _buildDefaultDio() {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 4),
receiveTimeout: const Duration(seconds: 8),
headers: {
'User-Agent': EmbyRequestHeaders.userAgent,
'Accept': 'application/json, text/plain, */*',
'accept-language': 'zh-CN,en,*',
},
),
);
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () =>
HttpClient()..idleTimeout = const Duration(seconds: 1),
);
return dio;
}
static const _matchConnectTimeout = Duration(seconds: 8);
static const _matchReceiveTimeout = Duration(seconds: 8);
static const _commentReceiveTimeout = Duration(seconds: 30);
@override
Future<List<DanmakuMatch>> match(
String baseUrl,
String fileName,
int durationSec,
) async {
try {
final resp = await _dio.post<Map<String, dynamic>>(
'$baseUrl/api/v2/match',
data: {
'fileHash': '',
'fileName': fileName,
'fileSize': 0,
'matchMode': 'fileName',
'videoDuration': durationSec,
},
options: Options(
contentType: 'application/json',
sendTimeout: _matchConnectTimeout,
receiveTimeout: _matchReceiveTimeout,
),
);
final data = resp.data;
if (data == null) {
AppLogger.warn(_tag, 'match($fileName) null response body');
return [];
}
if (data['success'] != true || data['isMatched'] != true) {
AppLogger.debug(
_tag,
'match($fileName) no result: success=${data['success']} '
'isMatched=${data['isMatched']}',
);
return [];
}
final list = (data['matches'] as List)
.map((e) => DanmakuMatch.fromJson(e as Map<String, dynamic>))
.toList();
AppLogger.debug(_tag, 'match($fileName) → ${list.length} entries');
return list;
} catch (e) {
AppLogger.warn(_tag, 'match($fileName) failed', e);
rethrow;
}
}
@override
Future<List<DanmakuMatch>> searchEpisodes(
String baseUrl,
String anime,
int? episode,
) async {
try {
final resp = await _dio.get<Map<String, dynamic>>(
'$baseUrl/api/v2/search/episodes',
queryParameters: {
'anime': anime,
if (episode != null) 'episode': episode,
},
options: Options(receiveTimeout: _matchReceiveTimeout),
);
final data = resp.data;
if (data == null || data['success'] != true) {
AppLogger.debug(
_tag,
'searchEpisodes($anime) no result: success=${data?['success']}',
);
return [];
}
final animes = data['animes'];
if (animes is! List) return [];
final out = <DanmakuMatch>[];
for (final a in animes.whereType<Map<String, dynamic>>()) {
final animeId = (a['animeId'] as num?)?.toInt() ?? 0;
final animeTitle = a['animeTitle']?.toString() ?? '';
final eps = a['episodes'];
if (eps is! List) continue;
for (final e in eps.whereType<Map<String, dynamic>>()) {
final epId = (e['episodeId'] as num?)?.toInt();
if (epId == null) continue;
out.add(
DanmakuMatch(
episodeId: epId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: e['episodeTitle']?.toString() ?? '',
),
);
}
}
AppLogger.debug(
_tag,
'searchEpisodes($anime ep=$episode) → ${out.length} entries',
);
return out;
} catch (e) {
AppLogger.warn(_tag, 'searchEpisodes($anime) failed', e);
rethrow;
}
}
@override
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
String baseUrl,
int animeId,
) async {
if (animeId <= 0) return [];
try {
final resp = await _dio.get<Map<String, dynamic>>(
'$baseUrl/api/v2/bangumi/$animeId',
);
final data = resp.data;
if (data == null || data['success'] != true) return [];
final bangumi = data['bangumi'];
if (bangumi is! Map<String, dynamic>) return [];
final episodes = bangumi['episodes'];
if (episodes is! List) return [];
return episodes
.whereType<Map<String, dynamic>>()
.map(DanmakuBangumiEpisode.fromJson)
.toList();
} catch (e) {
AppLogger.warn(_tag, 'getBangumiEpisodes($animeId) failed', e);
return [];
}
}
@override
Future<List<DanmakuComment>> fetchComments(
String baseUrl,
int episodeId, {
int chConvert = 0,
}) async {
try {
final resp = await _dio.get<Map<String, dynamic>>(
'$baseUrl/api/v2/comment/$episodeId'
'?format=json&chConvert=$chConvert&withRelated=true',
options: Options(receiveTimeout: _commentReceiveTimeout),
);
final data = resp.data;
if (data == null) {
AppLogger.warn(_tag, 'fetchComments($episodeId) null response body');
return [];
}
final list = (data['comments'] as List? ?? []).map((e) {
final m = e as Map<String, dynamic>;
return DanmakuComment.fromRaw(
(m['cid'] as num).toInt(),
m['p'] as String,
m['m'] as String,
);
}).toList();
AppLogger.debug(
_tag,
'fetchComments($episodeId) → ${list.length} comments',
);
return list;
} catch (e) {
AppLogger.warn(_tag, 'fetchComments($episodeId) failed', e);
rethrow;
}
}
}
@@ -0,0 +1,53 @@
import '../../contracts/danmaku.dart';
import '../../domain/ports/danmaku_gateway.dart';
class DispatchingDanmakuGateway implements DanmakuGateway {
final DanmakuGateway httpGateway;
final DanmakuGateway scriptWidgetGateway;
const DispatchingDanmakuGateway({
required this.httpGateway,
required this.scriptWidgetGateway,
});
DanmakuGateway _gatewayFor(String sourceUrl) {
return sourceUrl.startsWith('jsw://') ? scriptWidgetGateway : httpGateway;
}
@override
Future<List<DanmakuMatch>> match(
String baseUrl,
String fileName,
int durationSec,
) {
return _gatewayFor(baseUrl).match(baseUrl, fileName, durationSec);
}
@override
Future<List<DanmakuMatch>> searchEpisodes(
String baseUrl,
String anime,
int? episode,
) {
return _gatewayFor(baseUrl).searchEpisodes(baseUrl, anime, episode);
}
@override
Future<List<DanmakuComment>> fetchComments(
String baseUrl,
int episodeId, {
int chConvert = 0,
}) {
return _gatewayFor(
baseUrl,
).fetchComments(baseUrl, episodeId, chConvert: chConvert);
}
@override
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
String baseUrl,
int animeId,
) {
return _gatewayFor(baseUrl).getBangumiEpisodes(baseUrl, animeId);
}
}
@@ -0,0 +1,175 @@
import '../../contracts/danmaku.dart';
import '../../domain/ports/danmaku_gateway.dart';
import '../../domain/usecases/script_widget/script_widget_service.dart';
class ScriptWidgetDanmakuGateway implements DanmakuGateway {
final Future<ScriptWidgetService> Function() loadService;
const ScriptWidgetDanmakuGateway({required this.loadService});
@override
Future<List<DanmakuMatch>> match(
String baseUrl,
String fileName,
int durationSec,
) async {
final parsedContext = _parseFileName(fileName);
return _searchAndExpand(
baseUrl,
title: parsedContext.title,
type: parsedContext.episode == null ? 'movie' : 'tv',
season: parsedContext.season,
episode: parsedContext.episode,
durationSec: durationSec,
);
}
@override
Future<List<DanmakuMatch>> searchEpisodes(
String baseUrl,
String anime,
int? episode,
) {
return _searchAndExpand(
baseUrl,
title: anime,
type: 'tv',
episode: episode,
durationSec: 0,
);
}
Future<List<DanmakuMatch>> _searchAndExpand(
String sourceUrl, {
required String title,
required String type,
required int durationSec,
int? season,
int? episode,
}) async {
final service = await loadService();
final widgetId = _widgetIdFromSource(sourceUrl);
final rawSearch = await service.invoke(widgetId, 'searchDanmu', {
'title': title,
'seriesName': type == 'tv' ? title : null,
'type': type,
'season': season?.toString(),
'episode': episode?.toString(),
'runtime': durationSec,
});
final searchMap = _asMap(rawSearch);
final rawAnimes = searchMap['animes'];
if (rawAnimes is! List) return const <DanmakuMatch>[];
final matches = <DanmakuMatch>[];
for (final rawAnime in rawAnimes.whereType<Map>()) {
final anime = Map<String, dynamic>.from(rawAnime);
final animeId = _intValue(anime['animeId'] ?? anime['id']);
if (animeId == null) continue;
final animeTitle =
anime['animeTitle']?.toString() ??
anime['title']?.toString() ??
title;
final episodes = await getBangumiEpisodes(sourceUrl, animeId);
for (final candidate in episodes) {
final candidateNumber =
int.tryParse(candidate.episodeNumber) ??
_episodeNumberFromTitle(candidate.episodeTitle);
if (episode != null && candidateNumber != episode) continue;
matches.add(
DanmakuMatch(
episodeId: candidate.episodeId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: candidate.episodeTitle,
),
);
}
}
return matches;
}
@override
Future<List<DanmakuComment>> fetchComments(
String baseUrl,
int episodeId, {
int chConvert = 0,
}) async {
final service = await loadService();
final rawResult = await service.invoke(
_widgetIdFromSource(baseUrl),
'getComments',
<String, Object?>{'commentId': episodeId, 'chConvert': chConvert},
);
final resultMap = _asMap(rawResult);
final rawComments = resultMap['comments'] ?? rawResult;
if (rawComments is! List) return const <DanmakuComment>[];
final comments = <DanmakuComment>[];
for (final rawComment in rawComments.whereType<Map>()) {
final comment = Map<String, dynamic>.from(rawComment);
final cid = _intValue(comment['cid']) ?? comments.length;
final parameter = comment['p']?.toString();
final message = comment['m']?.toString();
if (parameter == null || message == null) continue;
comments.add(DanmakuComment.fromRaw(cid, parameter, message));
}
return comments;
}
@override
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
String baseUrl,
int animeId,
) async {
final service = await loadService();
final rawResult = await service.invoke(
_widgetIdFromSource(baseUrl),
'getDetail',
<String, Object?>{'animeId': animeId},
);
final rawEpisodes = _asMap(rawResult)['episodes'] ?? rawResult;
if (rawEpisodes is! List) return const <DanmakuBangumiEpisode>[];
final episodes = <DanmakuBangumiEpisode>[];
for (final rawEpisode in rawEpisodes.whereType<Map>()) {
try {
episodes.add(
DanmakuBangumiEpisode.fromJson(Map<String, dynamic>.from(rawEpisode)),
);
} catch (_) {}
}
return episodes;
}
}
String _widgetIdFromSource(String sourceUrl) => Uri.parse(sourceUrl).host;
({String title, int? season, int? episode}) _parseFileName(String fileName) {
final match = RegExp(
r'^(.*?)\s+S(\d+)E(\d+)\s*$',
caseSensitive: false,
).firstMatch(fileName.trim());
if (match == null) {
return (title: fileName.trim(), season: null, episode: null);
}
return (
title: match.group(1)?.trim() ?? fileName.trim(),
season: int.tryParse(match.group(2) ?? ''),
episode: int.tryParse(match.group(3) ?? ''),
);
}
int? _episodeNumberFromTitle(String title) {
final match = RegExp(r'(?:第\s*)?(\d+)(?:\s*[集话]|$)').firstMatch(title);
return int.tryParse(match?.group(1) ?? '');
}
Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return <String, dynamic>{};
}
int? _intValue(Object? value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:window_manager/window_manager.dart';
import '../../../shared/utils/app_logger.dart';
import '../trakt_api/trakt_credentials.dart';
class DeepLinkService {
DeepLinkService._();
static final DeepLinkService instance = DeepLinkService._();
static const _tag = 'DeepLink';
final AppLinks _appLinks = AppLinks();
final StreamController<Uri> _controller = StreamController<Uri>.broadcast();
bool _initialized = false;
Stream<Uri> get uriStream => _controller.stream;
Future<void> init() async {
if (_initialized) return;
_initialized = true;
_appLinks.uriLinkStream.listen(
_handle,
onError: (Object e) => AppLogger.warn(_tag, 'uri stream error', e),
);
try {
final initial = await _appLinks.getInitialLink();
if (initial != null) _handle(initial);
} catch (e) {
AppLogger.warn(_tag, 'getInitialLink failed', e);
}
}
void _handle(Uri uri) {
if (uri.scheme != TraktCredentials.scheme ||
uri.host != TraktCredentials.host) {
return;
}
AppLogger.info(_tag, 'received ${uri.scheme}://${uri.host}');
_bringToFront();
_controller.add(uri);
}
Future<void> _bringToFront() async {
try {
await windowManager.show();
await windowManager.focus();
} catch (_) {}
}
}
@@ -0,0 +1,706 @@
import 'dart:async';
import 'package:dio/dio.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/url_utils.dart';
import '../../contracts/contracts.dart';
import '../../domain/errors.dart';
import '../../domain/ports/emby_gateway.dart';
import 'emby_headers.dart';
class EmbyApiClient implements EmbyGateway {
static const _detailFields =
'BasicSyncInfo,RunTimeTicks,CommunityRating,ProductionYear,ChildCount,'
'Container,Overview,OfficialRating,Genres,Status,People,Studios,'
'ProviderIds,AlternateMediaSources,UserData,UserDataLastPlayedDate';
static const _seasonFields =
'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ChildCount,'
'ProductionYear,IndexNumber,Status,EndDate,Overview,'
'OfficialRating,Genres,CommunityRating,ProviderIds';
static const _episodeFields =
'BasicSyncInfo,RunTimeTicks,CommunityRating,Container,'
'People,PrimaryImageAspectRatio,DateCreated,Genres,'
'MediaStreams,MediaSources,UserData,UserDataLastPlayedDate,Path,ParentId,'
'Overview,Studios,ProviderIds,AlternateMediaSources,Chapters';
static const _similarFields =
'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,'
'Overview,OfficialRating,Genres,People,Studios,ProviderIds';
final Dio _dio;
final String _deviceId;
final String _deviceName;
EmbyApiClient({
Dio? dio,
required this._deviceId,
this._deviceName = 'iPad',
}) : _dio = dio ?? _createDio();
static Dio _createDio() => Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
),
);
Future<dynamic> _get(
String url, {
String? token,
String? userId,
Map<String, dynamic>? query,
CancellationToken? cancelToken,
}) async {
return _request(
method: 'GET',
url: url,
token: token,
userId: userId,
query: query,
cancelToken: cancelToken,
);
}
Future<dynamic> _post(
String url, {
String? token,
String? userId,
Map<String, dynamic>? query,
Object? body,
CancellationToken? cancelToken,
bool quiet = false,
}) async {
return _request(
method: 'POST',
url: url,
token: token,
userId: userId,
query: query,
body: body,
cancelToken: cancelToken,
quiet: quiet,
);
}
Future<dynamic> _delete(
String url, {
String? token,
String? userId,
Map<String, dynamic>? query,
CancellationToken? cancelToken,
}) async {
return _request(
method: 'DELETE',
url: url,
token: token,
userId: userId,
query: query,
cancelToken: cancelToken,
);
}
static const _tag = 'EmbyApi';
static String _extractPath(String url) {
final uri = Uri.tryParse(url);
if (uri == null) return url;
return uri.path;
}
Future<dynamic> _request({
required String method,
required String url,
String? token,
String? userId,
Map<String, dynamic>? query,
Object? body,
CancellationToken? cancelToken,
bool quiet = false,
}) async {
final path = _extractPath(url);
final queryLog = query != null && query.isNotEmpty
? ' query=${query.keys.join(',')}'
: '';
if (!quiet) {
AppLogger.debug(_tag, '--> $method $path$queryLog');
}
final stopwatch = Stopwatch()..start();
final headers = EmbyRequestHeaders.json(
deviceId: _deviceId,
deviceName: _deviceName,
token: token,
userId: userId,
hasBody: body != null,
);
CancelToken? dioCancelToken;
if (cancelToken != null) {
dioCancelToken = CancelToken();
cancelToken.whenCancelled.then((_) {
if (!dioCancelToken!.isCancelled) {
dioCancelToken.cancel(cancelToken.reason ?? 'cancelled');
}
});
}
try {
final response = await _dio.request<dynamic>(
url,
queryParameters: query,
data: body,
cancelToken: dioCancelToken,
options: Options(
method: method,
headers: headers,
responseType: ResponseType.json,
validateStatus: (s) => true,
),
);
stopwatch.stop();
final status = response.statusCode ?? 0;
final ms = stopwatch.elapsedMilliseconds;
if (!quiet) {
AppLogger.debug(_tag, '<-- $status $method $path (${ms}ms)');
}
if (status == 401) {
throw DomainError(
ErrorCode.authInvalidCredentials,
'用户名或密码错误',
details: {'status': status},
);
}
if (status == 403) {
throw DomainError(
ErrorCode.authForbidden,
'权限不足',
details: {'status': status},
);
}
if (status >= 400) {
AppLogger.warn(_tag, '<-- $status $method $path (${ms}ms)');
throw DomainError(
ErrorCode.serverHttpError,
'HTTP 请求失败($status',
retryable: status >= 500,
details: {'status': status},
);
}
return response.data;
} on DomainError {
rethrow;
} on DioException catch (e) {
stopwatch.stop();
final ms = stopwatch.elapsedMilliseconds;
if (CancelToken.isCancel(e)) {
AppLogger.debug(_tag, '<-- CANCELLED $method $path (${ms}ms)');
throw CancelledException(e.message);
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout) {
AppLogger.warn(_tag, '<-- TIMEOUT $method $path (${ms}ms)');
throw DomainError(
ErrorCode.serverTimeout,
'请求超时',
retryable: true,
details: {'message': e.message},
);
}
AppLogger.warn(_tag, '<-- NETWORK_ERROR $method $path (${ms}ms)', e);
throw DomainError(
ErrorCode.serverUnreachable,
'无法连接服务器',
retryable: true,
details: {'message': e.message},
);
} catch (e) {
stopwatch.stop();
AppLogger.warn(
_tag,
'<-- ERROR $method $path (${stopwatch.elapsedMilliseconds}ms)',
e,
);
throw DomainError(
ErrorCode.internalError,
'内部错误:$e',
details: {'message': e.toString()},
);
}
}
@override
Future<ProbePublicInfoRes> probePublicInfo(String baseUrl) async {
final url = '${stripTrailingSlash(baseUrl)}/System/Info/Public';
final json = await _get(url);
return ProbePublicInfoRes(
serverName: (json['ServerName'] ?? '') as String,
version: (json['Version'] ?? '') as String,
productName: (json['ProductName'] ?? 'Emby Server') as String,
);
}
@override
Future<EmbyAuthResult> authenticateByName({
required String baseUrl,
required String username,
required String password,
}) async {
final url = '${stripTrailingSlash(baseUrl)}/Users/AuthenticateByName';
final body = {'Username': username, 'Pw': password, 'Password': password};
final json = await _post(url, body: body);
final data = json as Map<String, dynamic>;
final user = data['User'] as Map<String, dynamic>?;
if (user == null) {
throw DomainError(ErrorCode.authInvalidCredentials, '认证响应格式错误');
}
return EmbyAuthResult(
accessToken: data['AccessToken'] as String,
userId: user['Id'] as String,
userName: user['Name'] as String,
);
}
@override
Future<void> changePassword({
required AuthedRequestContext ctx,
required String currentPassword,
required String newPassword,
}) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Password';
await _post(
url,
token: ctx.token,
userId: ctx.userId,
body: {'CurrentPw': currentPassword, 'NewPw': newPassword},
);
}
@override
Future<EmbyRawLibraries> getUserViews(AuthedRequestContext ctx) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Views';
final json = await _get(url, token: ctx.token, userId: ctx.userId);
return EmbyRawLibraries.fromJson(json);
}
@override
Future<LibraryLatestRes> getLatestItems(
AuthedRequestContext ctx,
String parentId, {
int? limit,
CancellationToken? cancelToken,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'ParentId': parentId,
'Fields':
'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,CanDelete',
'Recursive': 'true',
'IncludeItemTypes': 'Series,Movie,Video,MusicVideo,MusicAlbum',
'Limit': '${limit ?? 30}',
'EnableImageTypes': 'Primary,Backdrop,Thumb',
'SortBy': 'DateLastContentAdded,DateCreated,SortName',
'SortOrder': 'Descending',
'StartIndex': '0',
},
cancelToken: cancelToken,
);
final totalCount = (json['TotalRecordCount'] as num?)?.toInt() ?? 0;
return LibraryLatestRes(
parentId: parentId,
items: _parseItems(json),
totalCount: totalCount,
);
}
@override
Future<LibraryResumeRes> getResumeItems(
AuthedRequestContext ctx, {
int? limit,
}) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/Resume';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'Fields':
'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,ProviderIds,UserDataLastPlayedDate',
'Recursive': 'true',
'MediaTypes': 'Video',
'Limit': '${limit ?? 20}',
'ImageTypeLimit': '1',
'EnableImageTypes': 'Primary,Backdrop,Thumb',
},
);
return LibraryResumeRes(Items: _parseItems(json));
}
static List<EmbyRawItem> _parseItems(dynamic json) {
final items = json is List<dynamic>
? json
: (json['Items'] as List<dynamic>?) ?? <dynamic>[];
return items
.map((e) => EmbyRawItem.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<EmbyRawItem> getItemDetail(
AuthedRequestContext ctx,
String itemId,
) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'Fields': _detailFields,
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
},
);
return EmbyRawItem.fromJson(json);
}
@override
Future<void> reportPlaybackStarted({
required AuthedRequestContext ctx,
required PlaybackReportPayload payload,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing';
await _post(
url,
token: ctx.token,
userId: ctx.userId,
body: payload.toJson(includeNowPlayingQueue: true),
);
}
@override
Future<void> reportPlaybackProgress({
required AuthedRequestContext ctx,
required PlaybackReportPayload payload,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing/Progress';
await _post(
url,
token: ctx.token,
userId: ctx.userId,
body: payload.toJson(),
quiet: true,
);
}
@override
Future<void> reportPlaybackStopped({
required AuthedRequestContext ctx,
required PlaybackReportPayload payload,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing/Stopped';
await _post(
url,
token: ctx.token,
userId: ctx.userId,
body: payload.toJson(),
);
}
@override
Future<List<EmbyRawItem>> getSeriesNextUp(
AuthedRequestContext ctx,
String seriesId, {
int? limit,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/NextUp';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'UserId': ctx.userId,
'SeriesId': seriesId,
'Limit': '${limit ?? 1}',
'Fields':
'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,DateCreated,Genres,Path,ParentId,Overview',
},
);
return _parseItems(json);
}
@override
Future<List<EmbyRawSeason>> getSeriesSeasons(
AuthedRequestContext ctx,
String seriesId, {
CancellationToken? cancelToken,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/$seriesId/Seasons';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'UserId': ctx.userId,
'EnableTotalRecordCount': 'false',
'Fields': _seasonFields,
},
cancelToken: cancelToken,
);
final items = (json['Items'] as List<dynamic>?) ?? <dynamic>[];
return items
.map((e) => EmbyRawSeason.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<List<EmbyRawItem>> getSeriesEpisodes(
AuthedRequestContext ctx,
String seriesId,
String seasonId, {
CancellationToken? cancelToken,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/$seriesId/Episodes';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'UserId': ctx.userId,
'EnableTotalRecordCount': 'false',
'Fields': _episodeFields,
'EnableImageTypes': 'Primary,Backdrop,Thumb',
'SeasonId': seasonId,
},
cancelToken: cancelToken,
);
return _parseItems(json);
}
@override
Future<bool> setFavoriteStatus({
required AuthedRequestContext ctx,
required String itemId,
required bool isFavorite,
}) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/FavoriteItems/$itemId';
if (isFavorite) {
await _post(url, token: ctx.token, userId: ctx.userId);
} else {
await _delete(url, token: ctx.token, userId: ctx.userId);
}
return isFavorite;
}
@override
Future<bool> setPlayedStatus({
required AuthedRequestContext ctx,
required String itemId,
required bool played,
}) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/PlayedItems/$itemId';
if (played) {
await _post(url, token: ctx.token, userId: ctx.userId);
} else {
await _delete(url, token: ctx.token, userId: ctx.userId);
}
return played;
}
@override
Future<bool> hideFromResume({
required AuthedRequestContext ctx,
required String itemId,
required bool hide,
}) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId/HideFromResume';
await _post(
url,
token: ctx.token,
userId: ctx.userId,
query: {'Hide': hide ? 'true' : 'false'},
);
return hide;
}
@override
Future<List<EmbyRawItem>> getSimilarItems(
AuthedRequestContext ctx,
String itemId, {
int? limit,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/$itemId/Similar';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'Fields': _similarFields,
'Limit': '${limit ?? 10}',
'ImageTypes': 'Logo',
'SortBy': 'ProductionYear,CommunityRating,PlayCount',
'Recursive': 'true',
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'SortOrder': 'Descending',
'IncludeItemTypes': 'Series,Movie,Video',
'ImageTypeLimit': '1',
},
);
return _parseItems(json);
}
@override
Future<List<EmbyRawItem>> getAdditionalParts(
AuthedRequestContext ctx,
String itemId,
) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Videos/$itemId/AdditionalParts';
final json = await _get(url, token: ctx.token, userId: ctx.userId);
return _parseItems(json);
}
@override
Future<List<EmbyRawItem>> getSpecialFeatures(
AuthedRequestContext ctx,
String itemId,
) async {
final url =
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId/SpecialFeatures';
final json = await _get(url, token: ctx.token, userId: ctx.userId);
return _parseItems(json);
}
@override
Future<List<EmbyRawItem>> getLibraryItems({
required AuthedRequestContext ctx,
String? parentId,
String? includeItemTypes,
List<String>? genreIds,
String? searchTerm,
String? anyProviderIdEquals,
List<String>? personIds,
String? fields,
String? enableImageTypes,
bool? groupProgramsBySeries,
int? imageTypeLimit,
int? startIndex,
int? limit,
bool? recursive,
String? sortBy,
SortOrder? sortOrder,
String? filters,
CancellationToken? cancelToken,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items';
final query = <String, dynamic>{
if (parentId != null) 'ParentId': parentId,
if (includeItemTypes != null) 'IncludeItemTypes': includeItemTypes,
if (genreIds != null && genreIds.isNotEmpty)
'GenreIds': genreIds.join(','),
if (searchTerm != null) 'SearchTerm': searchTerm,
if (anyProviderIdEquals != null)
'AnyProviderIdEquals': anyProviderIdEquals,
if (personIds != null && personIds.isNotEmpty)
'PersonIds': personIds.join(','),
if (filters != null) 'Filters': filters,
'Fields':
fields ??
'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,CanDelete',
'EnableImageTypes': enableImageTypes ?? 'Primary,Backdrop,Thumb',
if (groupProgramsBySeries != null)
'GroupProgramsBySeries': groupProgramsBySeries ? 'true' : 'false',
if (imageTypeLimit != null) 'ImageTypeLimit': '$imageTypeLimit',
'StartIndex': '${startIndex ?? 0}',
'Limit': '${limit ?? 50}',
'Recursive': (recursive ?? true) ? 'true' : 'false',
'SortBy': sortBy ?? 'SortName',
'SortOrder': sortOrder?.value ?? 'Ascending',
};
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: query,
cancelToken: cancelToken,
);
return _parseItems(json);
}
@override
Future<List<EmbyRawItem>> getFavoriteItems({
required AuthedRequestContext ctx,
required String includeItemTypes,
required String sortBy,
SortOrder? sortOrder,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items';
final json = await _get(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'SortOrder': sortOrder?.value ?? 'Ascending',
'Filters': 'IsFavorite',
'Recursive': 'true',
'Fields':
'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,'
'Overview,UserDataLastPlayedDate,OfficialRating,Genres,Status,'
'People,Studios,ProviderIds',
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'CollapseBoxSetItems': 'false',
'IncludeItemTypes': includeItemTypes,
'SortBy': sortBy,
},
);
return _parseItems(json);
}
@override
Future<ItemCountsRes> getItemCounts(AuthedRequestContext ctx) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/Counts';
final json = await _get(url, token: ctx.token, userId: ctx.userId);
return ItemCountsRes.fromJson(json as Map<String, dynamic>);
}
@override
Future<Map<String, dynamic>> fetchPlaybackInfo({
required AuthedRequestContext ctx,
required String itemId,
String? mediaSourceId,
int startTimeTicks = 0,
bool isPlayback = false,
required Map<String, dynamic> deviceProfile,
}) async {
final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/$itemId/PlaybackInfo';
final json = await _post(
url,
token: ctx.token,
userId: ctx.userId,
query: {
'AutoOpenLiveStream': 'false',
'reqformat': 'json',
'StartTimeTicks': '$startTimeTicks',
'UserId': ctx.userId,
'MaxStreamingBitrate': '200000000',
'IsPlayback': isPlayback ? 'true' : 'false',
if (mediaSourceId != null) 'MediaSourceId': mediaSourceId,
},
body: {'DeviceProfile': deviceProfile},
);
return (json as Map<String, dynamic>);
}
}
+142
View File
@@ -0,0 +1,142 @@
class EmbyAuthHeader {
static String build({
required String deviceId,
required String deviceName,
String? token,
String? userId,
}) {
final parts = <String>[];
if (token != null) {
parts.add('Token="$token"');
}
if (userId != null && userId.isNotEmpty) {
parts.add('Emby UserId="$userId"');
}
parts.addAll([
'Client="${EmbyRequestHeaders.clientName}"',
'Device="$deviceName"',
'DeviceId="$deviceId"',
'Version="${EmbyRequestHeaders.version}"',
]);
return 'MediaBrowser ${parts.join(', ')}';
}
}
class EmbyRequestHeaders {
static const version = '6.1.0';
static const clientName = 'SenPlayer';
static const userAgent = 'SenPlayer/6.2.0';
static const acceptLanguage = 'zh-CN,zh-Hans;q=0.9';
static const embyLanguage = 'zh-cn';
static Map<String, String> json({
required String deviceId,
required String deviceName,
String? token,
String? userId,
bool hasBody = false,
}) {
return {
'Accept': 'application/json',
'Accept-Language': acceptLanguage,
'User-Agent': userAgent,
..._clientIdentity(deviceId: deviceId, deviceName: deviceName),
..._auth(
deviceId: deviceId,
deviceName: deviceName,
token: token,
userId: userId,
),
if (hasBody) 'Content-Type': 'application/json',
};
}
static Map<String, String> image({
required String deviceId,
required String deviceName,
required String token,
required String userId,
}) {
return {
'User-Agent': userAgent,
'Accept': 'image/*,*/*;q=0.8',
'Accept-Language': acceptLanguage,
'Priority': 'u=3, i',
..._clientIdentity(deviceId: deviceId, deviceName: deviceName),
..._auth(
deviceId: deviceId,
deviceName: deviceName,
token: token,
userId: userId,
),
};
}
static Map<String, String> media({
required String deviceId,
required String deviceName,
required String token,
required String userId,
}) {
return {
'User-Agent': userAgent,
'Accept': '*/*',
'Accept-Language': acceptLanguage,
..._clientIdentity(deviceId: deviceId, deviceName: deviceName),
..._auth(
deviceId: deviceId,
deviceName: deviceName,
token: token,
userId: userId,
),
};
}
static Map<String, String> directMedia() {
return {
'User-Agent': userAgent,
'Accept': '*/*',
'Accept-Language': acceptLanguage,
};
}
static Map<String, String> _clientIdentity({
required String deviceId,
required String deviceName,
}) {
return {
'X-Emby-Client': clientName,
'X-Emby-Client-Version': version,
'X-Emby-Device-Id': deviceId,
'X-Emby-Device-Name': deviceName,
'X-Emby-Language': embyLanguage,
};
}
static Map<String, String> _auth({
required String deviceId,
required String deviceName,
String? token,
String? userId,
}) {
final authorizationHeader = EmbyAuthHeader.build(
deviceId: deviceId,
deviceName: deviceName,
token: token,
userId: userId,
);
return {
'X-Emby-Authorization': authorizationHeader,
'Authorization': authorizationHeader,
if (token != null && token.isNotEmpty) ...{
'X-Emby-Token': token,
'X-MediaBrowser-Token': token,
},
};
}
}
@@ -0,0 +1,488 @@
import 'package:flutter/foundation.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/emby_ticks.dart';
import '../../../shared/utils/url_utils.dart';
import '../../contracts/contracts.dart';
import '../../domain/errors.dart';
import '../../domain/ports/emby_gateway.dart';
import 'emby_headers.dart';
const _tag = 'PlaybackResolver';
class PlaybackResolver {
final EmbyGateway gateway;
final String _deviceId;
final String _deviceName;
PlaybackResolver({
required this.gateway,
required this._deviceId,
this._deviceName = 'iPad',
});
Future<PlaybackRequestResult> prepare({
required AuthedRequestContext ctx,
required PlaybackRequestPayload payload,
EmbyRawItem? preloadedItem,
Map<String, dynamic>? preloadedPlaybackInfo,
}) async {
var item = preloadedItem?.Id == payload.itemId
? preloadedItem!
: await gateway.getItemDetail(ctx, payload.itemId);
if (item.Type == 'Series') {
preloadedPlaybackInfo = null;
final seasons = await gateway.getSeriesSeasons(ctx, item.Id);
if (seasons.isEmpty) {
throw DomainError(
ErrorCode.storeNotFound,
'剧集没有可用季',
details: {'seriesId': item.Id},
);
}
final episodes = await gateway.getSeriesEpisodes(
ctx,
item.Id,
seasons.first.Id,
);
if (episodes.isEmpty) {
throw DomainError(
ErrorCode.storeNotFound,
'剧集没有可用分集',
details: {'seriesId': item.Id},
);
}
item = episodes.first;
}
final resumePositionTicks = _startPositionTicksFor(payload, item);
final playbackInfo =
(preloadedPlaybackInfo != null && preloadedPlaybackInfo.isNotEmpty)
? preloadedPlaybackInfo
: await gateway.fetchPlaybackInfo(
ctx: ctx,
itemId: item.Id,
mediaSourceId: payload.mediaSourceId,
startTimeTicks: resumePositionTicks,
isPlayback: true,
deviceProfile: deviceProfile(),
);
final mediaSources =
(playbackInfo['MediaSources'] as List<dynamic>?) ?? <dynamic>[];
if (mediaSources.isEmpty) {
final serverErrorCode = playbackInfo['ErrorCode'] as String?;
final responseKeys = playbackInfo.keys.take(10).toList();
AppLogger.warn(
_tag,
'PlaybackInfo returned empty MediaSources for itemId=${item.Id} '
'name=${item.Name} serverErrorCode=$serverErrorCode '
'responseKeys=$responseKeys',
);
throw DomainError(
ErrorCode.storeNotFound,
serverErrorCode != null
? '没有可播放的媒体源(服务端:$serverErrorCode'
: '没有可播放的媒体源',
details: {
'itemId': item.Id,
'itemName': item.Name,
if (serverErrorCode != null) 'serverErrorCode': serverErrorCode,
if (payload.mediaSourceId != null)
'requestedMediaSourceId': payload.mediaSourceId,
},
);
}
Map<String, dynamic> sourceJson;
if (payload.mediaSourceId != null) {
sourceJson = mediaSources.cast<Map<String, dynamic>>().firstWhere(
(s) => s['Id'] == payload.mediaSourceId,
orElse: () => mediaSources.first as Map<String, dynamic>,
);
} else {
sourceJson = mediaSources.first as Map<String, dynamic>;
}
final source = EmbyRawMediaSource.fromJson(sourceJson);
final playSessionId =
(playbackInfo['PlaySessionId'] as String?) ??
(sourceJson['PlaySessionId'] as String?);
final streamUrl = _resolveStreamUrl(
ctx: ctx,
itemId: item.Id,
sourceJson: sourceJson,
playSessionId: playSessionId,
);
final subtitles = resolveSubtitles(
source.MediaStreams ?? [],
baseUrl: ctx.baseUrl,
token: ctx.token,
itemId: item.Id,
mediaSourceId: source.Id,
);
final audioTracks = _resolveAudioTracks(streams: source.MediaStreams ?? []);
final positionTicks = resumePositionTicks;
final startSeconds = positionTicks / kTicksPerSecond;
final durationTicks = item.RunTimeTicks;
final durationSeconds = durationTicks != null
? durationTicks / kTicksPerSecond
: null;
final playMethod = _resolvePlayMethod(sourceJson);
AppLogger.debug(
_tag,
'prepared item=${item.Name} (${item.Id}) source=${source.Id} '
'container=${source.Container} method=$playMethod '
'startSeconds=${startSeconds.toStringAsFixed(1)} '
'subs=${subtitles.length} audio=${audioTracks.length}',
);
return PlaybackRequestResult(
streamUrl: streamUrl,
container: _normalizeContainer(source.Container),
mimeType: _guessMimeType(source.Container),
directStream: sourceJson['SupportsDirectStream'] as bool? ?? false,
item: item,
mediaSource: source,
startPositionSeconds: startSeconds,
durationSeconds: durationSeconds,
subtitles: subtitles,
defaultSubtitleIndex: source.DefaultSubtitleStreamIndex,
audioTracks: audioTracks,
defaultAudioStreamIndex: source.DefaultAudioStreamIndex,
playSessionId: playSessionId,
playMethod: playMethod,
);
}
int _startPositionTicksFor(PlaybackRequestPayload payload, EmbyRawItem item) {
if (payload.playFromStart) return 0;
final explicitTicks = payload.startPositionTicks;
if (explicitTicks != null && explicitTicks > 0) return explicitTicks;
return item.UserData?.PlaybackPositionTicks ?? 0;
}
String _resolveStreamUrl({
required AuthedRequestContext ctx,
required String itemId,
required Map<String, dynamic> sourceJson,
String? playSessionId,
}) {
final base = stripTrailingSlash(ctx.baseUrl);
final mediaSourceId = sourceJson['Id'] as String?;
final container = _normalizeContainer(sourceJson['Container'] as String?);
final directStreamUrl = sourceJson['DirectStreamUrl'] as String?;
if (directStreamUrl != null && directStreamUrl.isNotEmpty) {
return _buildPlayableUrl(
base,
directStreamUrl,
ctx.token,
appendTokenToCrossOrigin: false,
);
}
final transcodingUrl = sourceJson['TranscodingUrl'] as String?;
if (transcodingUrl != null && transcodingUrl.isNotEmpty) {
return _buildPlayableUrl(
base,
transcodingUrl,
ctx.token,
appendTokenToCrossOrigin: false,
);
}
final ext = container ?? 'mkv';
final params = _staticStreamQueryParameters(
ctx: ctx,
mediaSourceId: mediaSourceId,
playSessionId: playSessionId,
);
final query = params.entries
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
.join('&');
return '$base/Videos/$itemId/stream.$ext?$query';
}
Map<String, String> _staticStreamQueryParameters({
required AuthedRequestContext ctx,
String? mediaSourceId,
String? playSessionId,
}) {
return {
'UserId': ctx.userId,
'IsPlayback': 'true',
'MaxStreamingBitrate': '500000000',
'Static': 'true',
'api_key': ctx.token,
'X-Emby-Authorization': EmbyAuthHeader.build(
deviceId: _deviceId,
deviceName: _deviceName,
),
'X-Emby-Client': EmbyRequestHeaders.clientName,
'X-Emby-Client-Version': EmbyRequestHeaders.version,
'X-Emby-Device-Id': _deviceId,
'X-Emby-Device-Name': _deviceName,
'X-Emby-Language': 'zh-cn',
'X-Emby-Token': ctx.token,
if (mediaSourceId != null) 'MediaSourceId': mediaSourceId,
if (playSessionId != null) 'PlaySessionId': playSessionId,
};
}
String _resolvePlayMethod(Map<String, dynamic> sourceJson) {
final directStreamUrl = sourceJson['DirectStreamUrl'] as String?;
if (directStreamUrl != null && directStreamUrl.isNotEmpty) {
return 'DirectStream';
}
final transcodingUrl = sourceJson['TranscodingUrl'] as String?;
if (transcodingUrl != null && transcodingUrl.isNotEmpty) {
return 'Transcode';
}
return 'DirectStream';
}
static String _buildPlayableUrl(
String base,
String rawUrl,
String token, {
bool appendTokenToCrossOrigin = true,
}) {
final baseUri = Uri.parse(base);
final rawUri = Uri.parse(rawUrl);
final playableUri = rawUri.hasScheme
? rawUri
: Uri.parse('$base${rawUrl.startsWith('/') ? '' : '/'}$rawUrl');
if (!appendTokenToCrossOrigin && playableUri.origin != baseUri.origin) {
return playableUri.toString();
}
if (playableUri.queryParameters.containsKey('api_key')) {
return playableUri.toString();
}
return playableUri
.replace(
queryParameters: {
...playableUri.queryParametersAll,
'api_key': token,
},
)
.toString();
}
@visibleForTesting
static List<EmbySubtitleTrack> resolveSubtitles(
List<EmbyRawMediaStream> streams, {
String? baseUrl,
String? token,
String? itemId,
String? mediaSourceId,
}) {
final subtitles = <EmbySubtitleTrack>[];
for (final stream in streams) {
if (stream.Type != 'Subtitle') continue;
final isExternal =
(stream.IsExternal ?? false) ||
stream.DeliveryMethod == 'External' ||
(stream.DeliveryUrl != null && stream.DeliveryUrl!.isNotEmpty);
if (isExternal) {
if (!isTextSubtitleCodec(stream.Codec)) continue;
final rawDeliveryUrl = stream.DeliveryUrl;
if (rawDeliveryUrl == null ||
rawDeliveryUrl.isEmpty ||
baseUrl == null ||
token == null) {
continue;
}
subtitles.add(
EmbySubtitleTrack(
index: stream.Index ?? 0,
title: stream.Title ?? stream.DisplayTitle,
language: stream.Language,
isDefault: stream.IsDefault ?? false,
isForced: stream.IsForced ?? false,
isExternal: true,
deliveryUrl: _buildPlayableUrl(
stripTrailingSlash(baseUrl),
rawDeliveryUrl,
token,
),
codec: stream.Codec,
),
);
continue;
}
subtitles.add(
EmbySubtitleTrack(
index: stream.Index ?? 0,
title: stream.Title ?? stream.DisplayTitle,
language: stream.Language,
isDefault: stream.IsDefault ?? false,
isForced: stream.IsForced ?? false,
codec: stream.Codec,
deliveryUrl: _embeddedTextSubtitleUrl(
baseUrl: baseUrl,
token: token,
itemId: itemId,
mediaSourceId: mediaSourceId,
index: stream.Index,
codec: stream.Codec,
),
),
);
}
return subtitles;
}
static String? _embeddedTextSubtitleUrl({
required String? baseUrl,
required String? token,
required String? itemId,
required String? mediaSourceId,
required int? index,
required String? codec,
}) {
if (!isTextSubtitleCodec(codec)) return null;
if (baseUrl == null ||
token == null ||
itemId == null ||
mediaSourceId == null ||
index == null) {
return null;
}
final extension = _subtitleDeliveryExtension(codec);
final path =
'/Videos/$itemId/$mediaSourceId/Subtitles/$index/0/Stream.$extension';
return _buildPlayableUrl(stripTrailingSlash(baseUrl), path, token);
}
static String _subtitleDeliveryExtension(String? codec) {
final normalized = (codec ?? 'srt').toLowerCase();
return normalized == 'webvtt' ? 'vtt' : normalized;
}
static bool isTextSubtitleCodec(String? codec) {
if (codec == null) return false;
const textCodecs = {'srt', 'subrip', 'vtt', 'webvtt', 'ass', 'ssa'};
return textCodecs.contains(codec.toLowerCase());
}
List<EmbyAudioTrack> _resolveAudioTracks({
required List<EmbyRawMediaStream> streams,
}) {
final tracks = <EmbyAudioTrack>[];
for (final stream in streams) {
if (stream.Type != 'Audio') continue;
tracks.add(
EmbyAudioTrack(
index: stream.Index ?? 0,
title: stream.Title,
language: stream.Language,
codec: stream.Codec,
channels: stream.Channels,
displayTitle: stream.DisplayTitle,
isDefault: stream.IsDefault ?? false,
),
);
}
return tracks;
}
static Map<String, dynamic> deviceProfile() {
return {
'MaxStaticBitrate': 200000000,
'MaxStreamingBitrate': 200000000,
'MusicStreamingTranscodingBitrate': 200000000,
'DirectPlayProfiles': [
{'Type': 'Video'},
{'Type': 'Audio'},
],
'TranscodingProfiles': [
{
'Container': 'ts',
'Type': 'Video',
'AudioCodec': 'aac,mp3,wav,ac3,eac3,flac,opus',
'VideoCodec': 'hevc,h264,h265,mpeg4',
'Context': 'Streaming',
'Protocol': 'hls',
'MaxAudioChannels': '6',
'MinSegments': '1',
'BreakOnNonKeyFrames': true,
'ManifestSubtitles': 'vtt',
},
],
'ContainerProfiles': <Map<String, dynamic>>[],
'SubtitleProfiles': [
{'Format': 'vtt', 'Method': 'External'},
{'Format': 'ass', 'Method': 'External'},
{'Format': 'ssa', 'Method': 'External'},
{'Format': 'srt', 'Method': 'External'},
{'Format': 'sub', 'Method': 'External'},
{'Format': 'subrip', 'Method': 'External'},
{'Format': 'smi', 'Method': 'External'},
{'Format': 'ttml', 'Method': 'External'},
{'Format': 'webvtt', 'Method': 'External'},
{'Format': 'dvdsub', 'Method': 'External'},
{'Format': 'sup', 'Method': 'External'},
{'Format': 'dvdsub', 'Method': 'Embed'},
{'Format': 'vobsub', 'Method': 'Embed'},
{'Format': 'vtt', 'Method': 'Embed'},
{'Format': 'ass', 'Method': 'Embed'},
{'Format': 'ssa', 'Method': 'Embed'},
{'Format': 'srt', 'Method': 'Embed'},
{'Format': 'sub', 'Method': 'Embed'},
{'Format': 'pgssub', 'Method': 'Embed'},
{'Format': 'pgs', 'Method': 'Embed'},
{'Format': 'subrip', 'Method': 'Embed'},
{'Format': 'smi', 'Method': 'Embed'},
{'Format': 'ttml', 'Method': 'Embed'},
{'Format': 'webvtt', 'Method': 'Embed'},
{'Format': 'mov_text', 'Method': 'Embed'},
{'Format': 'dvb_teletext', 'Method': 'Embed'},
{'Format': 'dvb_subtitle', 'Method': 'Embed'},
{'Format': 'dvbsub', 'Method': 'Embed'},
{'Format': 'idx', 'Method': 'Embed'},
{'Format': 'sup', 'Method': 'Embed'},
{'Format': 'vtt', 'Method': 'Hls'},
],
};
}
String? _normalizeContainer(String? raw) {
if (raw == null) return null;
final container = raw.split(',').first.trim().toLowerCase();
if (container.isEmpty) return null;
if (container == 'matroska') return 'mkv';
return container;
}
String? _guessMimeType(String? container) {
if (container == null) return null;
return switch (_normalizeContainer(container)) {
'mp4' || 'm4v' => 'video/mp4',
'mkv' => 'video/x-matroska',
'webm' => 'video/webm',
'avi' => 'video/x-msvideo',
'mov' => 'video/quicktime',
'ts' || 'm2ts' || 'mpegts' => 'video/mp2t',
_ => null,
};
}
}
@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../contracts/app_update.dart';
import '../emby_api/emby_headers.dart';
const String kSmPlayerReleaseOwner = 'YOUR_GITHUB_OWNER';
const String kSmPlayerReleaseRepo = 'YOUR_GITHUB_REPO';
const String kSmPlayerManifestTag = '_manifests';
class GithubReleasesClient {
static Uri _platformManifestUri(String platformKey) => Uri.parse(
'https://github.com/$kSmPlayerReleaseOwner/$kSmPlayerReleaseRepo'
'/releases/download/$kSmPlayerManifestTag/latest-$platformKey.json',
);
final Dio _dio;
GithubReleasesClient({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
responseType: ResponseType.json,
headers: {
'Accept': 'application/json',
'User-Agent': EmbyRequestHeaders.userAgent,
},
),
);
Future<AppUpdateInfo> fetchLatestRelease({
required String platformKey,
}) async {
final response = await _dio.get<dynamic>(
_platformManifestUri(platformKey).toString(),
);
final data = response.data;
final Map<String, dynamic> json;
if (data is Map<String, dynamic>) {
json = data;
} else if (data is String) {
final decoded = jsonDecode(data);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('GitHub release JSON root is not a map');
}
json = decoded;
} else {
throw FormatException(
'GitHub release JSON has unexpected type: ${data.runtimeType}',
);
}
final platformAssets = <String, AppUpdateAsset>{};
final rawPlatforms = json['platforms'];
if (rawPlatforms is Map) {
rawPlatforms.forEach((key, value) {
if (value is Map<String, dynamic>) {
final asset = AppUpdateAsset.fromJson(value);
if (asset.name.isNotEmpty) {
platformAssets[key.toString()] = asset;
}
}
});
}
final assets = platformAssets.values.toList(growable: true);
final rawAssets = json['assets'];
if (rawAssets is List) {
assets.addAll(
rawAssets
.whereType<Map<String, dynamic>>()
.map(AppUpdateAsset.fromJson)
.where((asset) => asset.name.isNotEmpty),
);
}
final htmlUrl = _firstNonEmpty([
json['html_url'],
json['htmlUrl'],
json['downloadUrl'],
_latestReleasePageUrl,
]);
return AppUpdateInfo(
version: _firstNonEmpty([json['version'], json['tag_name']]),
releaseNotes: _firstNonEmpty([json['notes'], json['body']]),
htmlUrl: Uri.tryParse(htmlUrl) ?? Uri(),
assets: assets,
platformAssets: platformAssets,
);
}
static String get _latestReleasePageUrl =>
'https://github.com/$kSmPlayerReleaseOwner/$kSmPlayerReleaseRepo/releases/latest';
static String _firstNonEmpty(Iterable<Object?> values) {
return values
.map((value) => value?.toString().trim() ?? '')
.firstWhere((value) => value.isNotEmpty, orElse: () => '');
}
}
@@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../contracts/icon_library.dart';
class IconLibraryClient {
final Dio _dio;
IconLibraryClient({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
responseType: ResponseType.json,
followRedirects: true,
),
);
Future<IconLibrary> fetch(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) {
throw ArgumentError('icon library URL is empty');
}
final response = await _dio.get<dynamic>(trimmed);
final data = response.data;
Map<String, dynamic> json;
if (data is Map<String, dynamic>) {
json = data;
} else if (data is String) {
final decoded = jsonDecode(data);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('icon library JSON root is not a map');
}
json = decoded;
} else {
throw FormatException(
'icon library JSON has unexpected type: ${data.runtimeType}',
);
}
return IconLibrary.parse(trimmed, json);
}
}
@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import '../../../shared/utils/app_logger.dart';
const _tag = 'AndroidPip';
enum PipAction { play, pause, next, prev, seekForward, seekBackward }
class AndroidPipController {
static const _methodChannel = MethodChannel('smplayer/pip');
static const _eventChannel = EventChannel('smplayer/pip_events');
AndroidPipController._();
static final AndroidPipController instance = AndroidPipController._();
StreamController<bool>? _pipModeController;
StreamController<PipAction>? _actionController;
StreamSubscription<dynamic>? _eventSub;
Future<bool> isSupported() async {
if (!Platform.isAndroid) return false;
try {
return (await _methodChannel.invokeMethod<bool>('isSupported')) ?? false;
} catch (e) {
AppLogger.warn(_tag, 'isSupported check failed', e);
return false;
}
}
Future<bool> isInPipMode() async {
if (!Platform.isAndroid) return false;
try {
return (await _methodChannel.invokeMethod<bool>('isInPipMode')) ?? false;
} catch (e) {
return false;
}
}
Future<void> configure({
int aspectX = 16,
int aspectY = 9,
bool autoEnterOnLeave = false,
bool isPlaying = false,
bool hasNext = false,
bool hasPrev = false,
bool useEpisodeActions = true,
}) async {
if (!Platform.isAndroid) return;
try {
await _methodChannel.invokeMethod<bool>('configure', {
'aspectX': aspectX,
'aspectY': aspectY,
'autoEnterOnLeave': autoEnterOnLeave,
'isPlaying': isPlaying,
'hasNext': hasNext,
'hasPrev': hasPrev,
'useEpisodeActions': useEpisodeActions,
});
} catch (e) {
AppLogger.warn(_tag, 'configure failed', e);
}
}
Future<bool> enterPip({int? aspectX, int? aspectY}) async {
if (!Platform.isAndroid) return false;
try {
return (await _methodChannel.invokeMethod<bool>('enterPip', {
'aspectX': aspectX ?? 16,
'aspectY': aspectY ?? 9,
})) ??
false;
} catch (e) {
AppLogger.warn(_tag, 'enterPip failed', e);
return false;
}
}
Future<void> updatePlaybackState({
required bool isPlaying,
required bool hasNext,
required bool hasPrev,
required bool useEpisodeActions,
}) async {
if (!Platform.isAndroid) return;
try {
await _methodChannel.invokeMethod<bool>('updatePlaybackState', {
'isPlaying': isPlaying,
'hasNext': hasNext,
'hasPrev': hasPrev,
'useEpisodeActions': useEpisodeActions,
});
} catch (e) {
AppLogger.warn(_tag, 'updatePlaybackState failed', e);
}
}
Stream<bool> get pipModeChanges {
_ensureEventListening();
return (_pipModeController ??= StreamController<bool>.broadcast()).stream;
}
Stream<PipAction> get actionStream {
_ensureEventListening();
return (_actionController ??= StreamController<PipAction>.broadcast())
.stream;
}
void _ensureEventListening() {
if (_eventSub != null) return;
_eventSub = _eventChannel.receiveBroadcastStream().listen(
(event) {
if (event is! Map) return;
final map = event.cast<Object?, Object?>();
final type = map['type'] as String?;
if (type == 'modeChanged') {
final isInPip = map['isInPip'] as bool? ?? false;
_pipModeController?.add(isInPip);
} else if (type == 'action') {
final action = _parseAction(map['action'] as String?);
if (action != null) {
_actionController?.add(action);
}
}
},
onError: (Object e) {
AppLogger.warn(_tag, 'event stream error', e);
},
);
}
Future<void> dispose() async {
final eventSub = _eventSub;
_eventSub = null;
await eventSub?.cancel();
await _pipModeController?.close();
_pipModeController = null;
await _actionController?.close();
_actionController = null;
}
PipAction? _parseAction(String? name) => switch (name) {
'play' => PipAction.play,
'pause' => PipAction.pause,
'next' => PipAction.next,
'prev' => PipAction.prev,
'seekForward' => PipAction.seekForward,
'seekBackward' => PipAction.seekBackward,
_ => null,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,609 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../contracts/cancellation.dart';
import '../../../shared/utils/app_logger.dart';
import 'player_engine.dart';
const _tag = 'Media3Player';
@visibleForTesting
bool isMedia3NetworkError(String code) => code.startsWith('ERROR_CODE_IO_');
class Media3PlayerEngine implements PlayerEngine {
Media3PlayerEngine({bool? isAndroid}) {
final onAndroid = isAndroid ?? Platform.isAndroid;
if (!onAndroid) {
throw UnsupportedError('Media3PlayerEngine is Android-only');
}
AppLogger.debug(_tag, 'construct');
}
static const MethodChannel _mc = MethodChannel('smplayer/media3_engine');
static const EventChannel _events = EventChannel(
'smplayer/media3_engine/events',
);
@override
final PlayerStreams stream = PlayerStreams();
@override
final PlayerState state = PlayerState();
@override
final ValueNotifier<int?> textureId = ValueNotifier<int?>(null);
@override
final ValueNotifier<int> textureVersion = ValueNotifier<int>(0);
@override
final ValueNotifier<ui.Size?> videoSize = ValueNotifier<ui.Size?>(null);
StreamSubscription<dynamic>? _eventSub;
final ValueNotifier<int?> _playerIdNotifier = ValueNotifier<int?>(null);
int? get _playerId => _playerIdNotifier.value;
set _playerId(int? v) => _playerIdNotifier.value = v;
int _generation = 0;
bool _disposed = false;
bool _completedEmitted = false;
double _lastNonZeroVolume = 100.0;
@override
String get displayName => 'media3';
@override
bool get usesPlatformSurface => true;
@override
Future<int?> totalRxBytes() async {
if (_disposed || _playerId == null) return null;
final value = await _mc.invokeMethod<dynamic>(
'getProperty',
<String, dynamic>{'playerId': _playerId, 'name': 'totalRxBytes'},
);
return (value as num?)?.toInt();
}
@override
bool isNetworkStreamError(String error) => isMedia3NetworkError(error);
@override
void configureHardwareDecoding(String mode) {}
@override
Future<void> open(
String url, {
Duration start = Duration.zero,
bool play = true,
Map<String, String>? headers,
CancellationToken? cancel,
}) async {
_ensureNotDisposed();
cancel?.throwIfCancelled();
await _ensureCreated();
if (_disposed) return;
cancel?.throwIfCancelled();
final generation = ++_generation;
final openFuture = _mc.invokeMethod<void>('open', <String, dynamic>{
'playerId': _playerId,
'url': url,
'headers': headers ?? const <String, String>{},
'startMs': start.inMilliseconds,
'play': play,
'generation': generation,
});
await _runCancellable(openFuture, cancel, generation);
if (_disposed) return;
cancel?.throwIfCancelled();
_completedEmitted = false;
videoSize.value = null;
state
..position = start
..buffer = start
..duration = Duration.zero
..playing = false
..completed = false
..tracks = const PlayerTracks()
..track = const PlayerTrack()
..subtitle = const []
..error = null;
_emitStateSnapshot();
await _invoke('replayKeyState');
}
Future<void> _runCancellable(
Future<void> future,
CancellationToken? cancel,
int generation,
) {
if (cancel == null) return future;
return Future.any<void>([
future,
cancel.whenCancelled.then<void>((_) async {
if (!_disposed && _playerId != null) {
++_generation;
try {
await _mc.invokeMethod<void>('stop', <String, dynamic>{
'playerId': _playerId,
'generation': _generation,
});
} catch (e) {
AppLogger.debug(_tag, 'stop on cancel failed', e);
}
}
throw CancelledException(cancel.reason);
}),
]);
}
@override
Future<void> play() async {
if (_disposed || _playerId == null) return;
await _invoke('play');
}
@override
Future<void> pause() async {
if (_disposed || _playerId == null) return;
await _invoke('pause');
}
@override
Future<void> stop() async {
if (_disposed || _playerId == null) return;
++_generation;
await _invoke('stop', <String, dynamic>{'generation': _generation});
}
@override
Future<void> seek(Duration position) async {
if (_disposed || _playerId == null) return;
final durationMs = state.duration.inMilliseconds;
final maxMs = durationMs <= 0 ? (1 << 62) : durationMs;
final target = Duration(
milliseconds: position.inMilliseconds.clamp(0, maxMs).toInt(),
);
state.position = target;
stream.addPosition(target);
await _invoke('seek', <String, dynamic>{
'positionMs': target.inMilliseconds,
});
}
@override
Future<void> setRate(double rate) async {
if (_disposed || _playerId == null) return;
state.rate = rate;
await _invoke('setRate', <String, dynamic>{'rate': rate});
}
@override
Future<void> setVolume(double volume) async {
if (_disposed) return;
final next = volume.clamp(0.0, 200.0).toDouble();
state.volume = next;
if (next > 0) _lastNonZeroVolume = next;
stream.addVolume(next);
if (_playerId == null) return;
await _invoke('setVolume', <String, dynamic>{'volume': next});
}
@override
Future<void> toggleMute() async {
if (state.volume <= 0) {
await setVolume(_lastNonZeroVolume <= 0 ? 100 : _lastNonZeroVolume);
} else {
await setVolume(0);
}
}
@override
Future<void> setAudioTrack(AudioTrack track) async {
if (_disposed || _playerId == null) return;
await _invoke('setAudioTrack', <String, dynamic>{'id': track.id});
state.track = state.track.copyWith(audio: track);
stream.addTrack(state.track);
}
@override
Future<void> setSubtitleTrack(SubtitleTrack track) async {
if (_disposed || _playerId == null) return;
await _invoke('setSubtitleTrack', <String, dynamic>{'id': track.id});
state.track = state.track.copyWith(subtitle: track);
stream.addTrack(state.track);
}
@override
void setNativeSubtitleRendering(bool enabled) {
}
@override
void setSubtitleStyle({
required double fontSize,
required double bgOpacity,
required int position,
}) {
if (_disposed || _playerId == null) return;
_invoke('setSubtitleStyle', <String, dynamic>{
'fontSize': fontSize,
'bgOpacity': bgOpacity,
'position': position,
});
}
@override
ValueListenable<PlaybackStats?> get debugStats => kEmptyPlaybackStats;
@override
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit) {
return _Media3PlatformVideoView(
playerId: _playerIdNotifier,
fit: fit,
onResizeMode: _setResizeMode,
);
}
Future<void> _setResizeMode(String mode) async {
if (_disposed || _playerId == null) return;
try {
await _mc.invokeMethod<void>('setResizeMode', <String, dynamic>{
'playerId': _playerId,
'mode': mode,
});
} catch (e) {
AppLogger.debug(_tag, 'setResizeMode($mode) failed', e);
}
}
@override
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
++_generation;
await _eventSub?.cancel();
_eventSub = null;
final id = _playerId;
_playerId = null;
if (id != null) {
try {
await _mc.invokeMethod<void>('dispose', <String, dynamic>{
'playerId': id,
});
} catch (e) {
AppLogger.warn(_tag, 'native dispose failed', e);
}
}
textureId.dispose();
textureVersion.dispose();
videoSize.dispose();
_playerIdNotifier.dispose();
stream.dispose();
}
Future<void> prewarmNative() async {
if (_disposed) return;
await _ensureCreated();
}
Future<void>? _createFuture;
Future<void> _ensureCreated() {
if (_playerId != null) return Future<void>.value();
return _createFuture ??= _doCreateNative();
}
Future<void> _doCreateNative() async {
try {
final result = await _mc.invokeMapMethod<String, dynamic>('create');
if (result == null) {
throw StateError('Media3 create returned null');
}
final createdPlayerId = (result['playerId'] as num).toInt();
if (_disposed) {
AppLogger.debug(
_tag,
'disposed during create, releasing orphan playerId=$createdPlayerId',
);
try {
await _mc.invokeMethod<void>('dispose', <String, dynamic>{
'playerId': createdPlayerId,
});
} catch (e) {
AppLogger.warn(_tag, 'orphan dispose failed', e);
}
return;
}
_playerId = createdPlayerId;
AppLogger.debug(
_tag,
'created playerId=$_playerId '
'ffmpeg=${result['ffmpegAvailable']} v=${result['ffmpegVersion']}',
);
_eventSub = _events.receiveBroadcastStream().listen(
_handleEvent,
onError: (Object e) {
if (_disposed) return;
AppLogger.warn(_tag, 'event stream error', e);
},
);
try {
await _mc.invokeMethod<void>('setVolume', <String, dynamic>{
'playerId': _playerId,
'volume': state.volume,
});
} catch (e) {
AppLogger.debug(_tag, 'initial setVolume replay failed', e);
}
} catch (e) {
_createFuture = null;
rethrow;
}
}
void _handleEvent(dynamic event) {
if (_disposed) return;
if (event is! Map) return;
if (event['playerId'] != _playerId) return;
final gen = (event['generation'] as num?)?.toInt();
if (gen == null || gen != _generation) return;
final type = event['type'] as String?;
switch (type) {
case 'position':
final ms = (event['positionMs'] as num?)?.toInt() ?? 0;
final pos = Duration(milliseconds: ms);
state.position = pos;
stream.addPosition(pos);
final bufMs = (event['bufferMs'] as num?)?.toInt();
if (bufMs != null) {
final buf = Duration(milliseconds: bufMs);
state.buffer = buf;
stream.addBuffer(buf);
}
case 'duration':
final ms = (event['durationMs'] as num?)?.toInt() ?? 0;
final dur = Duration(milliseconds: ms);
if (dur == state.duration) return;
state.duration = dur;
stream.addDuration(dur);
case 'playing':
final playing = event['playing'] == true;
if (state.playing == playing) return;
state.playing = playing;
stream.addPlaying(playing);
case 'completed':
if (_completedEmitted) return;
_completedEmitted = true;
state.completed = true;
stream.addCompleted(true);
case 'videoSize':
final w = (event['width'] as num?)?.toDouble() ?? 0;
final h = (event['height'] as num?)?.toDouble() ?? 0;
if (w > 0 && h > 0) {
videoSize.value = ui.Size(w, h);
}
case 'tracks':
final tracks = _decodeTracks(event);
if (tracks == state.tracks) return;
state.tracks = tracks;
stream.addTracks(tracks);
case 'subtitle':
final lines =
(event['lines'] as List?)?.cast<String>() ?? const <String>[];
state.subtitle = lines;
stream.addSubtitle(lines);
case 'volume':
final vol = (event['volume'] as num?)?.toDouble();
if (vol != null) {
state.volume = vol;
if (vol > 0) _lastNonZeroVolume = vol;
stream.addVolume(vol);
}
case 'error':
final code = event['code'] as String? ?? 'unknown';
state.error = code;
AppLogger.warn(_tag, 'media3 error: $code ${event['message'] ?? ''}');
stream.addError(code);
default:
AppLogger.debug(_tag, 'unknown event type: $type');
}
}
PlayerTracks _decodeTracks(Map<dynamic, dynamic> event) {
final audio = <AudioTrack>[];
for (final raw in (event['audio'] as List? ?? const [])) {
final m = raw as Map;
audio.add(
AudioTrack(
id: m['id'] as String,
index: (m['index'] as num?)?.toInt(),
title: (m['title'] as String?)?.isNotEmpty == true
? m['title'] as String
: null,
language: (m['language'] as String?)?.isNotEmpty == true
? m['language'] as String
: null,
),
);
}
final subtitle = <SubtitleTrack>[];
for (final raw in (event['subtitle'] as List? ?? const [])) {
final m = raw as Map;
subtitle.add(
SubtitleTrack(
id: m['id'] as String,
index: (m['index'] as num?)?.toInt(),
title: (m['title'] as String?)?.isNotEmpty == true
? m['title'] as String
: null,
language: (m['language'] as String?)?.isNotEmpty == true
? m['language'] as String
: null,
),
);
}
return PlayerTracks(audio: audio, subtitle: subtitle);
}
Future<void> _invoke(String method, [Map<String, dynamic>? args]) {
return _mc.invokeMethod<void>(method, <String, dynamic>{
'playerId': _playerId,
...?args,
});
}
void _ensureNotDisposed() {
if (_disposed) throw StateError('Media3PlayerEngine has been disposed');
}
void _emitStateSnapshot() {
stream.addPosition(state.position);
stream.addDuration(state.duration);
stream.addBuffer(state.buffer);
stream.addPlaying(state.playing);
stream.addCompleted(state.completed);
stream.addTracks(state.tracks);
stream.addTrack(state.track);
stream.addSubtitle(state.subtitle);
stream.addVolume(state.volume);
}
}
class _Media3PlatformVideoView extends StatefulWidget {
const _Media3PlatformVideoView({
required this.playerId,
required this.fit,
required this.onResizeMode,
});
static const String _viewType = 'smplayer/media3_surface';
final ValueListenable<int?> playerId;
final ValueListenable<BoxFit> fit;
final Future<void> Function(String mode) onResizeMode;
@override
State<_Media3PlatformVideoView> createState() =>
_Media3PlatformVideoViewState();
}
class _Media3PlatformVideoViewState extends State<_Media3PlatformVideoView> {
int? _lastSentPlayerId;
String? _lastSentMode;
@override
void initState() {
super.initState();
widget.playerId.addListener(_syncResizeMode);
widget.fit.addListener(_syncResizeMode);
_syncResizeMode();
}
@override
void didUpdateWidget(covariant _Media3PlatformVideoView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.playerId != widget.playerId) {
oldWidget.playerId.removeListener(_syncResizeMode);
widget.playerId.addListener(_syncResizeMode);
}
if (oldWidget.fit != widget.fit) {
oldWidget.fit.removeListener(_syncResizeMode);
widget.fit.addListener(_syncResizeMode);
}
_syncResizeMode();
}
@override
void dispose() {
widget.playerId.removeListener(_syncResizeMode);
widget.fit.removeListener(_syncResizeMode);
super.dispose();
}
void _syncResizeMode() {
final id = widget.playerId.value;
if (id == null) return;
final mode = _resizeModeForFit(widget.fit.value);
if (_lastSentPlayerId == id && _lastSentMode == mode) return;
_lastSentPlayerId = id;
_lastSentMode = mode;
unawaited(widget.onResizeMode(mode));
}
String _resizeModeForFit(BoxFit fit) {
return switch (fit) {
BoxFit.fill => 'fill',
BoxFit.cover => 'zoom',
_ => 'fit',
};
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int?>(
valueListenable: widget.playerId,
builder: (context, id, _) {
if (id == null) {
return const SizedBox.expand();
}
return PlatformViewLink(
key: ValueKey<int>(id),
viewType: _Media3PlatformVideoView._viewType,
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
gestureRecognizers:
const <Factory<OneSequenceGestureRecognizer>>{},
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
);
},
onCreatePlatformView: (params) {
final controller =
PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: _Media3PlatformVideoView._viewType,
layoutDirection: TextDirection.ltr,
creationParams: <String, dynamic>{'playerId': id},
creationParamsCodec: const StandardMessageCodec(),
onFocus: () => params.onFocusChanged(true),
)
..addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
)
..create();
return controller;
},
);
},
);
}
}
@@ -0,0 +1,615 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart' as mk;
import 'package:media_kit_video/media_kit_video.dart' as mkv;
import '../../contracts/cancellation.dart';
import '../../../shared/utils/app_logger.dart';
import 'player_engine.dart';
const _tag = 'MKPlayer';
@visibleForTesting
bool isMpvNetworkError(String error) {
final lower = error.toLowerCase();
if (lower.startsWith('tcp:') || lower.startsWith('tls:')) return true;
const patterns = [
'failed to open http',
'loading failed',
'connection refused',
'connection reset',
'connection timed out',
'network is unreachable',
'host not found',
'name resolution',
'http error 4',
'http error 5',
'tls handshake',
];
for (final p in patterns) {
if (lower.contains(p)) return true;
}
return false;
}
const int _mpvNetworkTimeoutSeconds = 60;
double clampMediaKitVolume(double volume) =>
volume.clamp(0.0, 200.0).toDouble();
PlayerTracks mapMkTracksToPlayerTracks(mk.Tracks mkTracks) {
final audio = <AudioTrack>[];
for (var i = 0; i < mkTracks.audio.length; i++) {
final t = mkTracks.audio[i];
if (t.id == 'auto' || t.id == 'no') continue;
audio.add(mapMkAudioTrack(t, index: i));
}
final subtitle = <SubtitleTrack>[];
for (var i = 0; i < mkTracks.subtitle.length; i++) {
final t = mkTracks.subtitle[i];
if (t.id == 'auto' || t.id == 'no') continue;
subtitle.add(mapMkSubtitleTrack(t, index: i));
}
return PlayerTracks(audio: audio, subtitle: subtitle);
}
AudioTrack mapMkAudioTrack(mk.AudioTrack t, {int? index}) {
if (t.id == 'auto') return const AudioTrack.auto();
if (t.id == 'no') return const AudioTrack.no();
return AudioTrack(
id: t.id,
index: index,
title: t.title?.isNotEmpty == true ? t.title : null,
language: t.language?.isNotEmpty == true ? t.language : null,
native: t,
);
}
SubtitleTrack mapMkSubtitleTrack(mk.SubtitleTrack t, {int? index}) {
if (t.id == 'auto') return const SubtitleTrack.no();
if (t.id == 'no') return const SubtitleTrack.no();
return SubtitleTrack(
id: t.id,
index: index,
title: t.title?.isNotEmpty == true ? t.title : null,
language: t.language?.isNotEmpty == true ? t.language : null,
native: t,
);
}
class MediaKitPlayerEngine implements PlayerEngine {
MediaKitPlayerEngine() {
AppLogger.debug(_tag, 'construct start');
_subscribeToStreams();
_configureNetworkTimeout();
_configureNetworkReconnect();
AppLogger.debug(_tag, 'construct done');
}
final mk.Player _player = mk.Player(
configuration: const mk.PlayerConfiguration(
title: 'smPlayer mpv',
libass: true,
logLevel: mk.MPVLogLevel.warn,
),
);
mkv.VideoController? _controller;
@override
final PlayerStreams stream = PlayerStreams();
@override
final PlayerState state = PlayerState();
@override
final ValueNotifier<int?> textureId = ValueNotifier<int?>(null);
@override
final ValueNotifier<int> textureVersion = ValueNotifier<int>(0);
@override
bool get usesPlatformSurface => false;
@override
bool isNetworkStreamError(String error) => isMpvNetworkError(error);
@override
Future<int?> totalRxBytes() async {
final native = _player.platform;
if (_disposed || native is! mk.NativePlayer) return null;
try {
final raw = await native.getProperty('cache-speed');
final speed = double.tryParse(raw);
if (speed == null || speed < 0) return _rxBytesAccum;
final dtMs = _rxStopwatch.elapsedMilliseconds;
_rxStopwatch.reset();
_rxStopwatch.start();
final clampedDtMs = dtMs.clamp(0, 5000);
_rxBytesAccum += (speed * clampedDtMs) ~/ 1000;
return _rxBytesAccum;
} catch (_) {
return null;
}
}
@override
final ValueNotifier<ui.Size?> videoSize = ValueNotifier<ui.Size?>(null);
bool _disposed = false;
bool _completedEmitted = false;
bool _playerReady = false;
double _lastNonZeroVolume = 100.0;
final List<StreamSubscription<dynamic>> _subs = [];
double? _pendingInitialVolume;
int _rxBytesAccum = 0;
final Stopwatch _rxStopwatch = Stopwatch();
void _subscribeToStreams() {
_subs.addAll([
_player.stream.playing.listen(_handlePlaying),
_player.stream.completed.listen(_handleCompleted),
_player.stream.position.listen(_handlePosition),
_player.stream.duration.listen(_handleDuration),
_player.stream.buffer.listen(_handleBuffer),
_player.stream.volume.listen(_handleVolume),
_player.stream.tracks.listen(_handleTracks),
_player.stream.track.listen(_handleTrack),
_player.stream.width.listen(_handleWidth),
_player.stream.height.listen(_handleHeight),
_player.stream.error.listen(_handleError),
_player.stream.log.listen(_handleMpvLog),
]);
}
int? _lastHttpErrorStatus;
static final RegExp _httpErrorStatusPattern = RegExp(r'HTTP error (\d{3})');
void _handleMpvLog(mk.PlayerLog log) {
final text = log.text;
if (text.contains('HTTP error')) {
final match = _httpErrorStatusPattern.firstMatch(text);
if (match != null) {
_lastHttpErrorStatus = int.tryParse(match.group(1)!);
}
}
if (!kDebugMode) return;
if (text.contains('request:') ||
text.contains('User-Agent') ||
text.contains('HTTP error') ||
text.contains('Location:')) {
AppLogger.debug(_tag, 'mpv[${log.prefix}] ${text.trim()}');
}
}
void _configureVolumeBoostLimit() {
_setMpvPropertyWhenReady('volume-max', '200');
}
void _configureNetworkTimeout() {
_setMpvPropertyWhenReady('network-timeout', '$_mpvNetworkTimeoutSeconds');
}
void _configureNetworkReconnect() {
const reconnectOptions =
'reconnect=1,'
'reconnect_streamed=1,'
'reconnect_on_network_error=1,'
'reconnect_on_http_error=4xx+5xx,'
'reconnect_delay_max=5';
_setMpvPropertyWhenReady('stream-lavf-o', reconnectOptions);
}
void _setMpvPropertyWhenReady(String propertyName, String propertyValue) {
final native = _player.platform;
if (native is! mk.NativePlayer) return;
AppLogger.debug(_tag, 'set $propertyName=$propertyValue queued');
unawaited(
native
.setProperty(propertyName, propertyValue)
.then(
(_) => AppLogger.debug(_tag, 'set $propertyName done'),
onError: (Object e) =>
AppLogger.debug(_tag, 'set $propertyName failed', e),
),
);
}
void _handlePlaying(bool playing) {
if (_disposed) return;
if (state.playing == playing) return;
state.playing = playing;
stream.addPlaying(playing);
}
void _handleCompleted(bool completed) {
if (_disposed) return;
if (!completed) return;
if (_completedEmitted) return;
_completedEmitted = true;
state.completed = true;
stream.addCompleted(true);
}
void _handlePosition(Duration pos) {
if (_disposed) return;
state.position = pos;
stream.addPosition(pos);
}
void _handleDuration(Duration dur) {
if (_disposed) return;
if (dur == state.duration) return;
state.duration = dur;
stream.addDuration(dur);
}
void _handleBuffer(Duration buf) {
if (_disposed) return;
state.buffer = buf;
stream.addBuffer(buf);
}
void _handleVolume(double vol) {
if (_disposed) return;
state.volume = vol;
if (vol > 0) _lastNonZeroVolume = vol;
stream.addVolume(vol);
}
void _handleTracks(mk.Tracks mkTracks) {
if (_disposed) return;
final tracks = mapMkTracksToPlayerTracks(mkTracks);
if (tracks == state.tracks) return;
state.tracks = tracks;
stream.addTracks(tracks);
}
void _handleTrack(mk.Track mkTrack) {
if (_disposed) return;
final next = PlayerTrack(
audio: mapMkAudioTrack(mkTrack.audio),
subtitle: mapMkSubtitleTrack(mkTrack.subtitle),
);
if (next == state.track) return;
state.track = next;
stream.addTrack(next);
}
void _handleWidth(int? w) {
if (_disposed) return;
_updateVideoSize(w, null);
}
void _handleHeight(int? h) {
if (_disposed) return;
_updateVideoSize(null, h);
}
void _handleError(String error) {
if (_disposed) return;
final status = _lastHttpErrorStatus;
final enriched = (status != null && !error.contains('(http '))
? '$error (http $status)'
: error;
state.error = enriched;
AppLogger.warn(_tag, 'mpv error: $enriched');
stream.addError(enriched);
}
void _updateVideoSize(int? newW, int? newH) {
final current = videoSize.value;
final w = newW?.toDouble() ?? current?.width ?? 0;
final h = newH?.toDouble() ?? current?.height ?? 0;
if (w > 0 && h > 0) {
videoSize.value = ui.Size(w, h);
}
}
@override
String get displayName => 'mpv';
@override
void configureHardwareDecoding(String mode) {
final native = _player.platform;
if (native is! mk.NativePlayer) return;
final mpvHwdec = mode == 'no' ? 'no' : mode;
native.setProperty('hwdec', mpvHwdec).catchError((e) {
AppLogger.debug(_tag, 'setProperty(hwdec, $mpvHwdec) failed', e);
});
AppLogger.debug(_tag, 'hwdec=$mpvHwdec');
}
@override
Future<void> open(
String url, {
Duration start = Duration.zero,
bool play = true,
Map<String, String>? headers,
CancellationToken? cancel,
}) async {
_ensureNotDisposed();
cancel?.throwIfCancelled();
AppLogger.debug(
_tag,
'open start start=${start.inMilliseconds}ms play=$play '
'headers=${headers?.keys.join(',') ?? '<none>'}',
);
_rxBytesAccum = 0;
_rxStopwatch.reset();
_rxStopwatch.start();
_completedEmitted = false;
_lastHttpErrorStatus = null;
videoSize.value = null;
state
..position = start
..buffer = start
..duration = Duration.zero
..playing = false
..completed = false
..tracks = const PlayerTracks()
..track = const PlayerTrack()
..subtitle = const []
..error = null;
_emitStateSnapshot();
final media = mk.Media(
url,
httpHeaders: headers,
start: start == Duration.zero ? null : start,
);
AppLogger.debug(_tag, 'player.open start');
await _runCancellable(_player.open(media, play: false), cancel);
AppLogger.debug(_tag, 'player.open done');
if (_disposed) return;
cancel?.throwIfCancelled();
_playerReady = true;
_configureVolumeBoostLimit();
final pendingVolume = _pendingInitialVolume;
if (pendingVolume != null) {
_pendingInitialVolume = null;
await setVolume(pendingVolume);
if (_disposed) return;
cancel?.throwIfCancelled();
}
if (start > Duration.zero) {
try {
await _runCancellable(_player.seek(start), cancel);
} on CancelledException {
rethrow;
} catch (e) {
AppLogger.debug(_tag, 'seek-on-open failed, continuing', e);
}
if (_disposed) return;
cancel?.throwIfCancelled();
}
if (play) {
AppLogger.debug(_tag, 'player.play start');
await _player.play();
AppLogger.debug(_tag, 'player.play done');
}
}
Future<T> _runCancellable<T>(Future<T> future, CancellationToken? cancel) {
if (cancel == null) return future;
return Future.any<T>([
future,
cancel.whenCancelled.then<T>((_) async {
try {
if (!_disposed) await _player.stop();
} catch (e) {
AppLogger.debug(_tag, 'stop on cancel failed', e);
}
throw CancelledException(cancel.reason);
}),
]);
}
@override
Future<void> play() async {
if (_disposed) return;
await _player.play();
}
@override
Future<void> pause() async {
if (_disposed) return;
await _player.pause();
}
@override
Future<void> stop() async {
if (_disposed) return;
await _player.stop();
}
@override
Future<void> seek(Duration position) async {
if (_disposed) return;
final durationMs = state.duration.inMilliseconds;
final maxMs = durationMs <= 0 ? (1 << 62) : durationMs;
final target = Duration(
milliseconds: position.inMilliseconds.clamp(0, maxMs).toInt(),
);
state.position = target;
stream.addPosition(target);
await _player.seek(target);
}
@override
Future<void> setRate(double rate) async {
if (_disposed) return;
state.rate = rate;
await _player.setRate(rate);
}
@override
Future<void> setVolume(double volume) async {
if (_disposed) return;
final next = clampMediaKitVolume(volume);
state.volume = next;
if (next > 0) _lastNonZeroVolume = next;
if (!_playerReady) {
_pendingInitialVolume = next;
} else {
await _player.setVolume(next);
}
stream.addVolume(next);
}
@override
Future<void> toggleMute() async {
if (state.volume <= 0) {
await setVolume(_lastNonZeroVolume <= 0 ? 100 : _lastNonZeroVolume);
} else {
await setVolume(0);
}
}
@override
Future<void> setAudioTrack(AudioTrack track) async {
if (_disposed) return;
if (track.no) {
await _player.setAudioTrack(mk.AudioTrack.no());
} else if (track.native is mk.AudioTrack) {
await _player.setAudioTrack(track.native as mk.AudioTrack);
} else {
AppLogger.warn(
_tag,
'setAudioTrack: no native mk.AudioTrack on ${track.id}',
);
}
state.track = state.track.copyWith(audio: track);
stream.addTrack(state.track);
}
@override
Future<void> setSubtitleTrack(SubtitleTrack track) async {
if (_disposed) return;
if (track.no) {
await _player.setSubtitleTrack(mk.SubtitleTrack.no());
} else if (track.native is mk.SubtitleTrack) {
await _player.setSubtitleTrack(track.native as mk.SubtitleTrack);
} else {
AppLogger.warn(
_tag,
'setSubtitleTrack: no native mk.SubtitleTrack on ${track.id} '
'(track globalIdx=${track.index} title=${track.title})',
);
}
state.track = state.track.copyWith(subtitle: track);
stream.addTrack(state.track);
}
@override
void setNativeSubtitleRendering(bool enabled) {
}
@override
void setSubtitleStyle({
required double fontSize,
required double bgOpacity,
required int position,
}) {}
@override
ValueListenable<PlaybackStats?> get debugStats => kEmptyPlaybackStats;
@override
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit) {
final controller = _ensureVideoController();
return ValueListenableBuilder<BoxFit>(
valueListenable: fit,
builder: (context, fitValue, _) => mkv.Video(
controller: controller,
fit: fitValue,
controls: null,
wakelock: false,
pauseUponEnteringBackgroundMode: false,
),
);
}
@override
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
for (final sub in _subs) {
await sub.cancel();
}
_subs.clear();
await _player.dispose();
textureId.dispose();
textureVersion.dispose();
videoSize.dispose();
stream.dispose();
}
mkv.VideoController _ensureVideoController() {
final existing = _controller;
if (existing != null) return existing;
AppLogger.debug(
_tag,
'video controller create start os=${Platform.operatingSystem}',
);
final controller = mkv.VideoController(
_player,
configuration: const mkv.VideoControllerConfiguration(
enableHardwareAcceleration: true,
),
);
AppLogger.debug(_tag, 'video controller create done');
_controller = controller;
return controller;
}
void _ensureNotDisposed() {
if (_disposed) throw StateError('MediaKitPlayerEngine has been disposed');
}
void _emitStateSnapshot() {
stream.addPosition(state.position);
stream.addDuration(state.duration);
stream.addBuffer(state.buffer);
stream.addPlaying(state.playing);
stream.addCompleted(state.completed);
stream.addTracks(state.tracks);
stream.addTrack(state.track);
stream.addSubtitle(state.subtitle);
stream.addVolume(state.volume);
}
}
@@ -0,0 +1,327 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../contracts/cancellation.dart';
int? streamOpenErrorHttpStatus(String error) {
final match = RegExp(r'\(http (\d{3})\)').firstMatch(error.toLowerCase());
if (match == null) return null;
return int.tryParse(match.group(1)!);
}
bool isTerminalStreamOpenError(String error) {
final status = streamOpenErrorHttpStatus(error);
if (status == null) return false;
if (status == 408 || status == 429) return false;
return status >= 400 && status < 500;
}
abstract class PlayerEngine {
PlayerStreams get stream;
PlayerState get state;
ValueListenable<int?> get textureId;
ValueListenable<int> get textureVersion;
ValueNotifier<ui.Size?> get videoSize;
String get displayName;
bool get usesPlatformSurface => false;
Future<int?> totalRxBytes() async => null;
bool isNetworkStreamError(String error) => false;
void configureHardwareDecoding(String mode);
Future<void> open(
String url, {
Duration start = Duration.zero,
bool play = true,
Map<String, String>? headers,
CancellationToken? cancel,
});
Future<void> play();
Future<void> pause();
Future<void> stop();
Future<void> seek(Duration position);
Future<void> setRate(double rate);
Future<void> setVolume(double volume);
Future<void> toggleMute();
Future<void> setAudioTrack(AudioTrack track);
Future<void> setSubtitleTrack(SubtitleTrack track);
void setNativeSubtitleRendering(bool enabled) {}
void setSubtitleStyle({
required double fontSize,
required double bgOpacity,
required int position,
});
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit);
ValueListenable<PlaybackStats?> get debugStats => kEmptyPlaybackStats;
Future<void> dispose();
}
final ValueNotifier<PlaybackStats?> kEmptyPlaybackStats =
ValueNotifier<PlaybackStats?>(null);
@immutable
class PlaybackStats {
final Duration position;
final int bufferLeadMs;
final double bitrateMbps;
final double rate;
final String decoder;
final bool isDolbyVision;
final String hwdec;
const PlaybackStats({
required this.position,
required this.bufferLeadMs,
required this.bitrateMbps,
required this.rate,
required this.decoder,
required this.isDolbyVision,
required this.hwdec,
});
}
class PlayerStreams {
final StreamController<Duration> _position =
StreamController<Duration>.broadcast();
final StreamController<Duration> _duration =
StreamController<Duration>.broadcast();
final StreamController<Duration> _buffer =
StreamController<Duration>.broadcast();
final StreamController<bool> _playing = StreamController<bool>.broadcast();
final StreamController<bool> _completed = StreamController<bool>.broadcast();
final StreamController<PlayerTracks> _tracks =
StreamController<PlayerTracks>.broadcast();
final StreamController<PlayerTrack> _track =
StreamController<PlayerTrack>.broadcast();
final StreamController<List<String>> _subtitle =
StreamController<List<String>>.broadcast();
final StreamController<double> _volume = StreamController<double>.broadcast();
final StreamController<String> _error = StreamController<String>.broadcast();
Stream<Duration> get position => _position.stream;
Stream<Duration> get duration => _duration.stream;
Stream<Duration> get buffer => _buffer.stream;
Stream<bool> get playing => _playing.stream;
Stream<bool> get completed => _completed.stream;
Stream<PlayerTracks> get tracks => _tracks.stream;
Stream<PlayerTrack> get track => _track.stream;
Stream<List<String>> get subtitle => _subtitle.stream;
Stream<double> get volume => _volume.stream;
Stream<String> get error => _error.stream;
void addPosition(Duration value) => _position.add(value);
void addDuration(Duration value) => _duration.add(value);
void addBuffer(Duration value) => _buffer.add(value);
void addPlaying(bool value) => _playing.add(value);
void addCompleted(bool value) => _completed.add(value);
void addTracks(PlayerTracks value) => _tracks.add(value);
void addTrack(PlayerTrack value) => _track.add(value);
void addSubtitle(List<String> value) => _subtitle.add(value);
void addVolume(double value) => _volume.add(value);
void addError(String value) => _error.add(value);
void dispose() {
_position.close();
_duration.close();
_buffer.close();
_playing.close();
_completed.close();
_tracks.close();
_track.close();
_subtitle.close();
_volume.close();
_error.close();
}
}
class PlayerState {
Duration position = Duration.zero;
Duration duration = Duration.zero;
Duration buffer = Duration.zero;
bool playing = false;
bool completed = false;
double volume = 100.0;
double rate = 1.0;
PlayerTracks tracks = const PlayerTracks();
PlayerTrack track = const PlayerTrack();
List<String> subtitle = const [];
String? error;
}
@immutable
class PlayerTracks {
final List<AudioTrack> audio;
final List<SubtitleTrack> subtitle;
const PlayerTracks({
this.audio = const <AudioTrack>[],
this.subtitle = const <SubtitleTrack>[],
});
List<SubtitleTrack> get embeddedSubtitles =>
subtitle.where((t) => t.id != 'auto' && t.id != 'no').toList();
List<AudioTrack> get embeddedAudio =>
audio.where((t) => t.id != 'auto' && t.id != 'no').toList();
@override
bool operator ==(Object other) {
return other is PlayerTracks &&
listEquals(other.audio, audio) &&
listEquals(other.subtitle, subtitle);
}
@override
int get hashCode =>
Object.hash(Object.hashAll(audio), Object.hashAll(subtitle));
}
@immutable
class PlayerTrack {
final AudioTrack audio;
final SubtitleTrack subtitle;
const PlayerTrack({
this.audio = const AudioTrack.auto(),
this.subtitle = const SubtitleTrack.no(),
});
PlayerTrack copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) {
return PlayerTrack(
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
);
}
@override
bool operator ==(Object other) {
return other is PlayerTrack &&
other.audio == audio &&
other.subtitle == subtitle;
}
@override
int get hashCode => Object.hash(audio, subtitle);
}
@immutable
class AudioTrack {
final String id;
final int? index;
final String? title;
final String? language;
final bool no;
final bool auto;
final Object? native;
const AudioTrack({
required this.id,
this.index,
this.title,
this.language,
this.no = false,
this.auto = false,
this.native,
});
const AudioTrack.no()
: id = 'no',
index = null,
title = null,
language = null,
no = true,
auto = false,
native = null;
const AudioTrack.auto()
: id = 'auto',
index = null,
title = null,
language = null,
no = false,
auto = true,
native = null;
@override
bool operator ==(Object other) {
return other is AudioTrack &&
other.id == id &&
other.index == index &&
other.title == title &&
other.language == language &&
other.no == no &&
other.auto == auto;
}
@override
int get hashCode => Object.hash(id, index, title, language, no, auto);
}
@immutable
class SubtitleTrack {
final String id;
final int? index;
final String? title;
final String? language;
final bool no;
final Object? native;
const SubtitleTrack({
required this.id,
this.index,
this.title,
this.language,
this.no = false,
this.native,
});
const SubtitleTrack.no()
: id = 'no',
index = null,
title = null,
language = null,
no = true,
native = null;
@override
bool operator ==(Object other) {
return other is SubtitleTrack &&
other.id == id &&
other.index == index &&
other.title == title &&
other.language == language &&
other.no == no;
}
@override
int get hashCode => Object.hash(id, index, title, language, no);
}
@@ -0,0 +1,36 @@
import '../../../shared/utils/app_logger.dart';
const _tag = 'SeekGuard';
class SeekGuard {
SeekGuard({this.tolerance = const Duration(seconds: 2)});
final Duration tolerance;
Duration? _target;
void arm(Duration target) {
_target = target > Duration.zero ? target : null;
}
void disarm() {
_target = null;
}
bool shouldSuppress(Duration reported) {
final guard = _target;
if (guard == null) return false;
if (reported < guard - tolerance) {
AppLogger.debug(
_tag,
'Suppressing reported position $reported below target $guard',
);
return true;
}
disarm();
return false;
}
}
@@ -0,0 +1,63 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class TextureVideoView extends StatelessWidget {
const TextureVideoView({
super.key,
required this.textureId,
required this.textureVersion,
required this.videoSize,
required this.fit,
});
final ValueListenable<int?> textureId;
final ValueListenable<int> textureVersion;
final ValueListenable<ui.Size?> videoSize;
final ValueListenable<BoxFit> fit;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int?>(
valueListenable: textureId,
builder: (context, id, _) {
if (id == null || id < 0) {
return const SizedBox.expand();
}
return ValueListenableBuilder<int>(
valueListenable: textureVersion,
builder: (context, version, _) {
final texture = Texture(
key: ValueKey<int>(Object.hash(id, version)),
textureId: id,
);
return ValueListenableBuilder<ui.Size?>(
valueListenable: videoSize,
builder: (context, size, _) {
if (size == null || size.width <= 0 || size.height <= 0) {
return texture;
}
return ValueListenableBuilder<BoxFit>(
valueListenable: fit,
builder: (context, fitValue, _) => SizedBox.expand(
child: FittedBox(
fit: fitValue,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: size.width,
height: size.height,
child: texture,
),
),
),
);
},
);
},
);
},
);
}
}
@@ -0,0 +1,521 @@
import 'dart:async';
import 'dart:convert';
import 'package:fjs/fjs.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart' show rootBundle;
import '../../../shared/utils/app_logger.dart';
import '../../contracts/error.dart';
import '../../contracts/script_widget.dart';
import '../../domain/errors.dart';
import '../../domain/ports/script_widget_runtime.dart';
import 'widget_host_bridge.dart';
const String _tag = 'QuickJsWidgetRuntime';
const Duration _defaultInvocationTimeout = Duration(seconds: 30);
const int _maximumStackBytes = 1024 * 1024;
const int _maximumHeapBytes = 64 * 1024 * 1024;
const JsEvalOptions _scriptEvalOptions = JsEvalOptions.raw(
global: true,
strict: false,
backtraceBarrier: false,
promise: true,
);
class QuickJsWidgetRuntime implements ScriptWidgetRuntime {
final WidgetHostBridge _hostBridge;
final Duration invocationTimeout;
final Map<String, _LoadedWidgetContext> _contexts =
<String, _LoadedWidgetContext>{};
QuickJsWidgetRuntime({
WidgetHostBridge? hostBridge,
this.invocationTimeout = _defaultInvocationTimeout,
}) : _hostBridge = hostBridge ?? WidgetHostBridge(),
assert(invocationTimeout > Duration.zero);
@override
Future<ScriptWidgetManifest> inspectWidget(String scriptSource) async {
if (scriptSource.trim().isEmpty) {
throw DomainError(ErrorCode.invalidParams, '模块脚本不能为空');
}
return _readManifest(scriptSource);
}
@override
Future<ScriptWidgetManifest> loadWidget(String scriptSource) async {
final manifest = await inspectWidget(scriptSource);
final bootstrapData = await _hostBridge.loadBootstrapData(manifest.id);
final engine = await _createEngine();
try {
await engine.init(
bridge: (JsValue message) => _dispatchHostMessage(manifest.id, message),
);
await _installHostBootstrap(engine, bootstrapData);
await _evaluateOrThrow(engine, scriptSource);
final newContext = _LoadedWidgetContext(engine: engine);
final existingContext = _contexts[manifest.id];
_contexts[manifest.id] = newContext;
existingContext?.dispose();
return manifest;
} catch (_) {
unawaited(engine.close().catchError((_) {}));
rethrow;
}
}
@override
bool isWidgetLoaded(String widgetId) => _contexts.containsKey(widgetId);
Future<ScriptWidgetManifest> _readManifest(String scriptSource) async {
final engine = await _createEngine();
try {
await engine.initWithoutBridge();
await _evaluateOrThrow(
engine,
'var Widget = {}; '
'var module = { exports: {} }; '
'var exports = module.exports;',
);
await _evaluateOrThrow(engine, scriptSource);
final metadataResult = await _evaluateOrThrow(
engine,
"typeof WidgetMetadata === 'object' && WidgetMetadata !== null "
'? JSON.stringify(WidgetMetadata) : null',
);
final rawMetadata = metadataResult.value;
final decodedMetadata = rawMetadata is String
? jsonDecode(rawMetadata)
: null;
if (decodedMetadata is! Map) {
throw DomainError(ErrorCode.invalidParams, '脚本缺少有效的 WidgetMetadata 对象');
}
final manifest = ScriptWidgetManifest.fromJson(
Map<String, dynamic>.from(decodedMetadata),
);
if (manifest.id.trim().isEmpty || manifest.title.trim().isEmpty) {
throw DomainError(
ErrorCode.invalidParams,
'WidgetMetadata.id 和 title 不能为空',
);
}
return manifest;
} on DomainError {
rethrow;
} catch (error) {
throw DomainError(ErrorCode.invalidParams, '无法解析模块元数据', details: error);
} finally {
unawaited(engine.close().catchError((_) {}));
}
}
@override
Future<Object?> invokeModuleFunction(
String widgetId,
String functionName,
Map<String, Object?> params,
) async {
final context = _contexts[widgetId];
if (context == null) {
throw DomainError(ErrorCode.storeNotFound, '模块尚未加载:$widgetId');
}
if (functionName.trim().isEmpty) {
throw DomainError(ErrorCode.invalidParams, '模块函数名不能为空');
}
_drainBackgroundErrors(context.engine, widgetId);
final encodedFunctionName = jsonEncode(functionName);
final rawArgument = params['__forwardRawArgument'];
final encodedArgument = rawArgument == null
? jsonEncode(params)
: jsonEncode(rawArgument);
final JsValue invocationResult;
try {
invocationResult = await context.engine
.eval(
source: JsCode.code('''
await (async function() {
var functionName = $encodedFunctionName;
var moduleFunction = globalThis[functionName];
if (typeof moduleFunction !== 'function') {
throw new Error('Module function not found: ' + functionName);
}
return await moduleFunction($encodedArgument);
})();
'''),
options: _scriptEvalOptions,
)
.timeout(invocationTimeout);
} on TimeoutException catch (error) {
await unloadWidget(widgetId);
throw _timeoutError(widgetId, functionName, error);
} on DomainError {
rethrow;
} catch (error) {
await unloadWidget(widgetId);
throw _executionError(widgetId, functionName, error.toString());
}
return normalizeQuickJsValue(invocationResult.value);
}
DomainError _timeoutError(
String widgetId,
String functionName,
Object error,
) {
return DomainError(
ErrorCode.serverTimeout,
'模块执行超过 ${invocationTimeout.inSeconds} 秒,已终止运行环境',
retryable: true,
details: <String, Object?>{
'widgetId': widgetId,
'functionName': functionName,
'error': error.toString(),
},
);
}
@override
Future<void> unloadWidget(String widgetId) async {
_contexts.remove(widgetId)?.dispose();
_hostBridge.clearWidget(widgetId);
}
@override
Future<void> dispose() async {
final contexts = _contexts.values.toList(growable: false);
_contexts.clear();
for (final context in contexts) {
context.dispose();
}
_hostBridge.clear();
}
Future<JsResult> _dispatchHostMessage(String widgetId, JsValue message) async {
try {
final decoded = message.value;
final request = decoded is Map
? Map<String, dynamic>.from(decoded)
: const <String, dynamic>{};
final channel = request['channel']?.toString() ?? '';
final rawPayload = request['payload'];
final result = await _hostBridge.handleMessage(
widgetId,
channel,
rawPayload is Map
? Map<String, dynamic>.from(rawPayload)
: <String, dynamic>{},
);
return JsResult.ok(JsValue.from(result));
} catch (error) {
return JsResult.err(JsError.bridge(error.toString()));
}
}
void _drainBackgroundErrors(JsEngine engine, String widgetId) {
for (final backgroundError in engine.drainUnhandledJobErrors()) {
AppLogger.warn(_tag, '[$widgetId] 脚本后台异步错误:$backgroundError');
}
}
}
Future<void>? _libFjsInit;
Future<void> _ensureLibFjsReady() {
return _libFjsInit ??= LibFjs.init().then<void>(
(_) {},
onError: (Object error) {
if (error is StateError) {
return;
}
_libFjsInit = null;
throw DomainError(
ErrorCode.internalError,
'脚本引擎初始化失败',
details: error.toString(),
);
},
);
}
Future<JsEngine> _createEngine() async {
await _ensureLibFjsReady();
return JsEngine.create(
builtins: const JsBuiltinOptions(timers: true, url: true),
runtimeOptions: JsEngineRuntimeOptions(
memoryLimit: BigInt.from(_maximumHeapBytes),
maxStackSize: BigInt.from(_maximumStackBytes),
),
);
}
class _LoadedWidgetContext {
final JsEngine engine;
const _LoadedWidgetContext({required this.engine});
void dispose() {
unawaited(engine.close().catchError((_) {}));
}
}
Future<JsValue> _evaluateOrThrow(JsEngine engine, String source) async {
try {
return await engine.eval(
source: JsCode.code(source),
options: _scriptEvalOptions,
);
} on DomainError {
rethrow;
} catch (error) {
throw DomainError(
ErrorCode.invalidParams,
'模块脚本执行失败',
details: error.toString(),
);
}
}
@visibleForTesting
Object? normalizeQuickJsValue(Object? value) {
if (value is Map) {
return <String, dynamic>{
for (final entry in value.entries)
entry.key?.toString() ?? '': normalizeQuickJsValue(entry.value),
};
}
if (value is List) {
return value.map(normalizeQuickJsValue).toList(growable: false);
}
return value;
}
DomainError _executionError(
String widgetId,
String functionName,
String details,
) {
return DomainError(
ErrorCode.internalError,
'模块函数执行失败:$functionName',
details: <String, Object?>{
'widgetId': widgetId,
'functionName': functionName,
'error': details,
},
);
}
Future<String>? _cheerioBundle;
Future<String> _cheerioBundleSource() =>
_cheerioBundle ??= rootBundle.loadString('assets/js/cheerio.min.js');
Future<void> _installHostBootstrap(
JsEngine engine,
WidgetHostBootstrapData bootstrapData,
) async {
final encodedBootData = jsonEncode(<String, Object?>{
'storage': bootstrapData.storage,
'sharedCache': bootstrapData.sharedCache,
});
await _evaluateOrThrow(
engine,
'globalThis.__widgetHostBootData = $encodedBootData;',
);
await _evaluateOrThrow(engine, await _cheerioBundleSource());
await _evaluateOrThrow(engine, _kHostBootstrapJs);
}
const String _kHostBootstrapJs = r'''
(function() {
var bootData = globalThis.__widgetHostBootData || {};
var widgetStorageData = bootData.storage || {};
var sharedCacheData = bootData.sharedCache || {};
delete globalThis.__widgetHostBootData;
if (typeof globalThis.module === 'undefined') {
globalThis.module = { exports: {} };
}
if (typeof globalThis.exports === 'undefined') {
globalThis.exports = globalThis.module.exports;
}
if (typeof globalThis.global === 'undefined') {
globalThis.global = globalThis;
}
if (typeof globalThis.self === 'undefined') {
globalThis.self = globalThis;
}
function hostCall(channel, payload) {
return fjs.bridge_call({ channel: channel, payload: payload }).catch(
function(rejection) {
var message = (rejection instanceof Error)
? rejection.message
: String(rejection != null ? rejection : 'unknown host error');
throw new Error(message);
}
);
}
function hostNotify(channel, payload) {
try {
hostCall(channel, payload).catch(function() {});
} catch (error) {}
}
function persistStorage() {
hostNotify('WidgetStoragePersist', { storage: widgetStorageData });
}
function persistSharedCache() {
hostNotify('WidgetSharedCachePersist', { sharedCache: sharedCacheData });
}
var cheerioModule = globalThis.__fjsCheerio;
function loadHtml(html) {
return cheerioModule.load(String(html == null ? '' : html));
}
var domEntries = {};
var domNextHandle = 1;
function domRegister(doc, node) {
var handle = domNextHandle++;
domEntries[handle] = { doc: doc, node: node };
return handle;
}
function normalizeConsoleValue(value) {
if (value instanceof Error) {
return {
__consoleErrorMarker: true,
name: value.name || 'Error',
message: value.message || String(value),
stack: value.stack || ''
};
}
return value;
}
function forwardConsole(level) {
return function() {
var values = [];
for (var i = 0; i < arguments.length; i++) {
values.push(normalizeConsoleValue(arguments[i]));
}
hostNotify('WidgetConsole', { level: level, values: values });
};
}
globalThis.console = {
log: forwardConsole('log'),
info: forwardConsole('info'),
debug: forwardConsole('debug'),
warn: forwardConsole('warn'),
error: forwardConsole('error'),
trace: forwardConsole('debug')
};
globalThis.Widget = {
http: {
get: function(url, options) {
return hostCall('WidgetHttp', { method: 'GET', url: url, options: options || {} });
},
post: function(url, body, options) {
return hostCall('WidgetHttp', { method: 'POST', url: url, body: body, options: options || {} });
},
put: function(url, body, options) {
return hostCall('WidgetHttp', { method: 'PUT', url: url, body: body, options: options || {} });
},
delete: function(url, options) {
return hostCall('WidgetHttp', { method: 'DELETE', url: url, options: options || {} });
}
},
storage: {
get: function(key) { return widgetStorageData[key]; },
set: function(key, value) { widgetStorageData[key] = value; persistStorage(); },
remove: function(key) { delete widgetStorageData[key]; persistStorage(); },
getItem: function(key) { return widgetStorageData[key]; },
setItem: function(key, value) { widgetStorageData[key] = value; persistStorage(); },
removeItem: function(key) { delete widgetStorageData[key]; persistStorage(); }
},
sharedCache: {
get: function(namespace, key) {
var namespaceData = sharedCacheData[namespace] || {};
return namespaceData[key];
},
set: function(namespace, key, value) {
if (!sharedCacheData[namespace]) sharedCacheData[namespace] = {};
sharedCacheData[namespace][key] = value;
persistSharedCache();
},
remove: function(namespace, key) {
if (sharedCacheData[namespace]) delete sharedCacheData[namespace][key];
persistSharedCache();
}
},
html: { load: loadHtml },
dom: {
parse: function(html) {
try {
return domRegister(loadHtml(html), null);
} catch (error) {
return null;
}
},
select: function(handle, selector) {
var entry = domEntries[handle];
if (!entry || !selector) return [];
try {
var matched = entry.node == null
? entry.doc(selector)
: entry.doc(entry.node).find(selector);
return matched.toArray().map(function(node) {
return domRegister(entry.doc, node);
});
} catch (error) {
return [];
}
},
text: function(handle) {
var entry = domEntries[handle];
if (!entry) return '';
return entry.node == null
? entry.doc('body').text()
: entry.doc(entry.node).text();
},
html: function(handle) {
var entry = domEntries[handle];
if (!entry) return '';
var value = entry.node == null
? entry.doc.html()
: entry.doc(entry.node).html();
return value == null ? '' : value;
},
attr: function(handle, name) {
var entry = domEntries[handle];
if (!entry || entry.node == null) return null;
var value = entry.doc(entry.node).attr(String(name));
return value == null ? null : value;
}
}
};
})();
''';
@@ -0,0 +1,390 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show visibleForTesting;
const int scriptHttpMaximumResponseBytes = 10 * 1024 * 1024;
const int _maximumRedirects = 5;
const Duration _connectTimeout = Duration(seconds: 15);
const Duration _receiveTimeout = Duration(seconds: 30);
class SafeScriptHttpResponse {
final Object? data;
final String text;
final int statusCode;
final String reasonPhrase;
final Map<String, String> headers;
const SafeScriptHttpResponse({
required this.data,
required this.text,
required this.statusCode,
required this.reasonPhrase,
required this.headers,
});
}
class SafeScriptHttpException implements Exception {
final String message;
const SafeScriptHttpException(this.message);
@override
String toString() => message;
}
class SafeScriptHttpClient {
Future<SafeScriptHttpResponse> request({
required Uri uri,
String method = 'GET',
Map<String, String> headers = const <String, String>{},
Map<String, Object?> queryParameters = const <String, Object?>{},
Object? body,
bool followRedirects = true,
}) async {
var currentUri = _appendQueryParameters(uri, queryParameters);
var currentMethod = method.toUpperCase();
if (!_allowedMethods.contains(currentMethod)) {
throw const SafeScriptHttpException('HTTP method is not permitted');
}
var currentHeaders = _sanitizeRequestHeaders(headers);
var currentBody = body;
for (var redirectCount = 0; ; redirectCount++) {
final addresses = await _resolvePublicAddresses(currentUri);
final httpClient = _createPinnedHttpClient(currentUri, addresses.first);
try {
final request = await httpClient.openUrl(currentMethod, currentUri);
request.followRedirects = false;
currentHeaders.forEach(request.headers.set);
_writeRequestBody(request, currentBody, currentHeaders);
final response = await request.close().timeout(_receiveTimeout);
final location = response.headers.value(HttpHeaders.locationHeader);
final shouldRedirect =
followRedirects &&
location != null &&
response.isRedirect;
if (shouldRedirect) {
if (redirectCount >= _maximumRedirects) {
throw const SafeScriptHttpException('HTTP redirect limit exceeded');
}
final redirectedUri = currentUri.resolve(location);
final changesOrigin = !_hasSameOrigin(currentUri, redirectedUri);
if (changesOrigin) {
currentHeaders = _removeSensitiveHeaders(currentHeaders);
}
if (_redirectsAsGet(response.statusCode, currentMethod)) {
currentMethod = 'GET';
currentBody = null;
currentHeaders = _removeEntityHeaders(currentHeaders);
}
currentUri = redirectedUri;
continue;
}
final responseBytes = await _readLimitedResponse(response);
final responseText = _decodeResponseText(responseBytes, response);
return SafeScriptHttpResponse(
data: _decodeResponseData(responseText, response),
text: responseText,
statusCode: response.statusCode,
reasonPhrase: response.reasonPhrase,
headers: _responseHeaders(response),
);
} finally {
httpClient.close(force: true);
}
}
}
}
HttpClient _createPinnedHttpClient(Uri uri, InternetAddress address) {
final httpClient = HttpClient()
..connectionTimeout = _connectTimeout
..idleTimeout = _receiveTimeout
..findProxy = (_) => 'DIRECT';
httpClient.connectionFactory =
(Uri connectionUri, String? proxyHost, int? proxyPort) async {
if (proxyHost != null || proxyPort != null) {
throw const SafeScriptHttpException('HTTP proxies are not permitted');
}
if (!_hasSameOrigin(uri, connectionUri)) {
throw const SafeScriptHttpException('Unexpected connection target');
}
final socketTask = await Socket.startConnect(
address,
connectionUri.port,
);
if (connectionUri.scheme != 'https') return socketTask;
final Future<Socket> secureSocket = socketTask.socket.then(
(socket) => SecureSocket.secure(socket, host: connectionUri.host),
);
return ConnectionTask.fromSocket<Socket>(
secureSocket,
socketTask.cancel,
);
};
return httpClient;
}
Future<List<InternetAddress>> _resolvePublicAddresses(Uri uri) async {
if (!isValidScriptHttpUri(uri)) {
throw const SafeScriptHttpException(
'Only valid HTTP(S) URLs are permitted',
);
}
final literalAddress = InternetAddress.tryParse(uri.host);
final resolvedAddresses = literalAddress == null
? await InternetAddress.lookup(uri.host).timeout(_connectTimeout)
: <InternetAddress>[literalAddress];
return filterPublicScriptAddresses(host: uri.host, addresses: resolvedAddresses);
}
@visibleForTesting
List<InternetAddress> filterPublicScriptAddresses({
required String host,
required List<InternetAddress> addresses,
}) {
if (addresses.isEmpty) {
throw SafeScriptHttpException('Could not resolve host: $host');
}
final publicAddresses = addresses
.where(isPublicScriptAddress)
.toList(growable: false);
if (publicAddresses.isEmpty) {
final rejectedAddresses = addresses
.map((address) => address.address)
.join(', ');
throw SafeScriptHttpException(
'Private or special-purpose network addresses are not permitted '
'(host=$host, addresses=$rejectedAddresses)',
);
}
return publicAddresses;
}
bool isPublicScriptAddress(InternetAddress address) {
final bytes = address.rawAddress;
if (address.type == InternetAddressType.IPv4) {
return _isPublicIpv4(bytes);
}
if (address.type != InternetAddressType.IPv6 || bytes.length != 16) {
return false;
}
final isIpv4Mapped =
bytes.take(10).every((byte) => byte == 0) &&
bytes[10] == 0xff &&
bytes[11] == 0xff;
if (isIpv4Mapped) return _isPublicIpv4(bytes.sublist(12));
final isUnspecifiedOrLoopback = bytes
.take(15)
.every((byte) => byte == 0) && (bytes[15] == 0 || bytes[15] == 1);
if (isUnspecifiedOrLoopback) return false;
if ((bytes[0] & 0xfe) == 0xfc) return false;
if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80) return false;
final isGlobalUnicast = (bytes[0] & 0xe0) == 0x20;
if (!isGlobalUnicast) return false;
return true;
}
bool _isPublicIpv4(List<int> bytes) {
if (bytes.length != 4) return false;
final first = bytes[0];
final second = bytes[1];
final third = bytes[2];
if (first == 0 || first == 10 || first == 127) return false;
if (first == 169 && second == 254) return false;
if (first == 172 && second >= 16 && second <= 31) return false;
if (first == 192 && second == 0 && third == 0) return false;
if (first == 192 && second == 168) return false;
if (first >= 224) return false;
return true;
}
Future<Uint8List> _readLimitedResponse(HttpClientResponse response) async {
return collectScriptResponseBytes(
response.timeout(_receiveTimeout),
declaredLength: response.contentLength,
);
}
Future<Uint8List> collectScriptResponseBytes(
Stream<List<int>> responseStream, {
int declaredLength = -1,
int maximumBytes = scriptHttpMaximumResponseBytes,
}) async {
if (declaredLength > maximumBytes) {
throw const SafeScriptHttpException('HTTP response exceeds 10 MB');
}
final builder = BytesBuilder(copy: false);
var receivedBytes = 0;
await for (final chunk in responseStream) {
receivedBytes += chunk.length;
if (receivedBytes > maximumBytes) {
throw const SafeScriptHttpException('HTTP response exceeds 10 MB');
}
builder.add(chunk);
}
return builder.takeBytes();
}
void _writeRequestBody(
HttpClientRequest request,
Object? body,
Map<String, String> headers,
) {
if (body == null) return;
if (body is List<int>) {
request.add(body);
return;
}
if (body is String) {
request.write(body);
return;
}
final hasContentType = headers.keys.any(
(name) => name.toLowerCase() == HttpHeaders.contentTypeHeader,
);
if (!hasContentType) {
request.headers.contentType = ContentType.json;
}
request.write(jsonEncode(body));
}
String _decodeResponseText(
Uint8List responseBytes,
HttpClientResponse response,
) {
final charset = response.headers.contentType?.charset?.toLowerCase();
if (charset == 'iso-8859-1' || charset == 'latin1') {
return latin1.decode(responseBytes);
}
return utf8.decode(responseBytes, allowMalformed: true);
}
Object? _decodeResponseData(String responseText, HttpClientResponse response) {
if (responseText.isEmpty) return responseText;
final mimeType = response.headers.contentType?.mimeType.toLowerCase() ?? '';
final declaredAsJson =
mimeType == 'application/json' || mimeType.endsWith('+json');
final looksLikeJson =
responseText.startsWith('{') || responseText.startsWith('[');
if (!declaredAsJson && !looksLikeJson) return responseText;
try {
return jsonDecode(responseText);
} catch (_) {
return responseText;
}
}
Map<String, String> _responseHeaders(HttpClientResponse response) {
final headers = <String, String>{};
response.headers.forEach((name, values) {
headers[name] = values.join(', ');
});
return headers;
}
Map<String, String> _sanitizeRequestHeaders(Map<String, String> headers) {
return _removeHeaders(headers, const <String>{
'connection',
'content-length',
'host',
'proxy-authorization',
'proxy-connection',
'transfer-encoding',
'upgrade',
});
}
Map<String, String> _removeSensitiveHeaders(Map<String, String> headers) {
return _removeHeaders(headers, const <String>{'authorization', 'cookie'});
}
Map<String, String> _removeEntityHeaders(Map<String, String> headers) {
return _removeHeaders(headers, const <String>{
'content-encoding',
'content-language',
'content-type',
});
}
Map<String, String> _removeHeaders(
Map<String, String> headers,
Set<String> excludedNames,
) {
return <String, String>{
for (final entry in headers.entries)
if (!excludedNames.contains(entry.key.toLowerCase()))
entry.key: entry.value,
};
}
Uri _appendQueryParameters(Uri uri, Map<String, Object?> queryParameters) {
if (queryParameters.isEmpty) return uri;
return uri.replace(
queryParameters: <String, dynamic>{
...uri.queryParametersAll,
for (final entry in queryParameters.entries)
if (entry.value != null)
entry.key: entry.value is Iterable
? (entry.value! as Iterable<Object?>)
.map((value) => value?.toString() ?? '')
.toList(growable: false)
: entry.value.toString(),
},
);
}
bool isValidScriptHttpUri(Uri uri) {
return (uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority &&
uri.host.isNotEmpty &&
uri.userInfo.isEmpty;
}
const Set<String> _allowedMethods = <String>{
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS',
};
bool _redirectsAsGet(int statusCode, String method) {
return statusCode == HttpStatus.seeOther ||
((statusCode == HttpStatus.movedPermanently ||
statusCode == HttpStatus.found) &&
method == 'POST');
}
bool _hasSameOrigin(Uri left, Uri right) {
return left.scheme == right.scheme &&
left.host.toLowerCase() == right.host.toLowerCase() &&
left.port == right.port;
}
@@ -0,0 +1,35 @@
import 'dart:async';
import '../../contracts/error.dart';
import '../../domain/errors.dart';
import '../../domain/ports/script_widget_remote_gateway.dart';
import 'safe_script_http_client.dart';
class ScriptWidgetRemoteClient implements ScriptWidgetRemoteGateway {
final SafeScriptHttpClient _httpClient;
ScriptWidgetRemoteClient({SafeScriptHttpClient? httpClient})
: _httpClient = httpClient ?? SafeScriptHttpClient();
@override
Future<String> fetchText(String url) async {
final uri = Uri.tryParse(url.trim());
if (uri == null) {
throw DomainError(ErrorCode.invalidParams, '模块地址必须是有效的 HTTP(S) URL');
}
try {
final response = await _httpClient.request(uri: uri);
return response.text;
} on DomainError {
rethrow;
} catch (error) {
final isTimeout = error is TimeoutException;
throw DomainError(
isTimeout ? ErrorCode.serverTimeout : ErrorCode.serverUnreachable,
isTimeout ? '下载模块超时' : '无法下载模块',
retryable: true,
details: <String, Object?>{'url': url, 'error': error.toString()},
);
}
}
}
@@ -0,0 +1,197 @@
import 'dart:convert';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../shared/utils/async_lock.dart';
import '../../../shared/utils/app_logger.dart';
import 'safe_script_http_client.dart';
typedef WidgetHostBootstrapData = ({
Map<String, Object?> storage,
Map<String, Object?> sharedCache,
});
const String _browserUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/131.0.0.0 Safari/537.36';
class WidgetHostBridge {
static const String _tag = 'ScriptWidgetHost';
static const String _storagePrefix = 'smplayer.script_widget.storage.';
static const String _sharedCachePrefix =
'smplayer.script_widget.shared_cache.';
final SafeScriptHttpClient _httpClient;
final Map<String, AsyncLock> _persistenceLocks = <String, AsyncLock>{};
WidgetHostBridge({SafeScriptHttpClient? httpClient})
: _httpClient = httpClient ?? SafeScriptHttpClient();
Future<WidgetHostBootstrapData> loadBootstrapData(String widgetId) async {
final preferences = await SharedPreferences.getInstance();
return (
storage: _decodeObjectMap(
preferences.getString('$_storagePrefix$widgetId'),
),
sharedCache: _decodeObjectMap(
preferences.getString('$_sharedCachePrefix$widgetId'),
),
);
}
Future<Object?> handleMessage(
String widgetId,
String channel,
Map<String, dynamic> payload,
) async {
switch (channel) {
case 'WidgetHttp':
return _handleHttpRequest(payload);
case 'WidgetStoragePersist':
return _persistJson(
'$_storagePrefix$widgetId',
_asMap(payload['storage']),
);
case 'WidgetSharedCachePersist':
return _persistJson(
'$_sharedCachePrefix$widgetId',
_asMap(payload['sharedCache']),
);
case 'WidgetConsole':
final level = payload['level']?.toString() ?? 'log';
final rawValues = payload['values'];
final message = rawValues is List
? rawValues.map(formatConsoleValue).join(' ')
: formatConsoleValue(rawValues);
switch (level) {
case 'error':
AppLogger.error(_tag, '[$widgetId] $message', message);
case 'warn':
AppLogger.warn(_tag, '[$widgetId] $message');
case 'info':
AppLogger.info(_tag, '[$widgetId] $message');
default:
AppLogger.debug(_tag, '[$widgetId] $message');
}
return true;
default:
throw ArgumentError('未知的宿主通道:$channel');
}
}
Future<Map<String, Object?>> _handleHttpRequest(
Map<String, dynamic> request,
) async {
final method = request['method']?.toString().toUpperCase() ?? 'GET';
final url = request['url']?.toString() ?? '';
final uri = Uri.tryParse(url);
if (uri == null) {
throw ArgumentError('Widget.http only permits HTTP(S) URLs');
}
final optionsMap = _asMap(request['options']);
final scriptHeaders = <String, String>{
for (final entry in _asMap(optionsMap['headers']).entries)
entry.key: entry.value.toString(),
};
final hasUserAgent = scriptHeaders.keys.any(
(headerName) => headerName.toLowerCase() == 'user-agent',
);
final headers = <String, String>{
if (!hasUserAgent) 'User-Agent': _browserUserAgent,
...scriptHeaders,
};
final queryParameters = <String, Object?>{
for (final entry in _asMap(optionsMap['params']).entries)
entry.key: entry.value,
};
final response = await _httpClient.request(
uri: uri,
method: method,
headers: headers,
queryParameters: queryParameters,
body: request['body'],
followRedirects: optionsMap['allow_redirects'] != false,
);
return <String, Object?>{
'data': response.data,
'status': response.statusCode,
'statusCode': response.statusCode,
'statusText': response.reasonPhrase,
'headers': response.headers,
};
}
Future<bool> _persistJson(
String preferenceKey,
Map<String, dynamic> value,
) => _persistenceLocks.putIfAbsent(preferenceKey, AsyncLock.new).run(() async {
final preferences = await SharedPreferences.getInstance();
return preferences.setString(preferenceKey, jsonEncode(value));
});
void clearWidget(String widgetId) {
_persistenceLocks.remove('$_storagePrefix$widgetId');
_persistenceLocks.remove('$_sharedCachePrefix$widgetId');
}
void clear() => _persistenceLocks.clear();
}
Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
if (value is String) {
try {
final decodedValue = jsonDecode(value);
if (decodedValue is Map) {
return Map<String, dynamic>.from(decodedValue);
}
} catch (_) {}
}
return <String, dynamic>{};
}
Map<String, Object?> _decodeObjectMap(String? rawJson) {
if (rawJson == null || rawJson.trim().isEmpty) return <String, Object?>{};
try {
return _asMap(jsonDecode(rawJson));
} catch (_) {
return <String, Object?>{};
}
}
@visibleForTesting
String formatConsoleValue(Object? value) {
if (value == null) return 'null';
if (value is String) return value;
if (value is num || value is bool) return value.toString();
if (value is Map) {
if (value['__consoleErrorMarker'] == true) {
final errorName = value['name']?.toString() ?? 'Error';
final errorMessage = value['message']?.toString() ?? '';
final errorStack = value['stack']?.toString() ?? '';
final formatted = errorMessage.isEmpty
? errorName
: '$errorName: $errorMessage';
return errorStack.isEmpty ? formatted : '$formatted\n$errorStack';
}
try {
return jsonEncode(value);
} catch (_) {
return value.toString();
}
}
if (value is Iterable) {
try {
return jsonEncode(value.toList(growable: false));
} catch (_) {
return value.toString();
}
}
return value.toString();
}
@@ -0,0 +1,119 @@
import 'package:dio/dio.dart';
import '../../domain/errors.dart';
import '../../contracts/error.dart';
import '../../domain/ports/server_sync_gateway.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/url_utils.dart';
class ServerSyncClient implements ServerSyncGateway {
static const _tag = 'ServerSync';
final Dio _dio;
ServerSyncClient({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 15),
headers: {'Accept': 'application/json'},
validateStatus: (status) => status != null && status < 500,
),
);
@override
Future<ServerSyncSnapshot> pull({
required String baseUrl,
required String accessToken,
}) async {
final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/server-sync';
try {
final res = await _dio.get<Map<String, dynamic>>(
url,
options: Options(headers: {'Authorization': 'Bearer $accessToken'}),
);
final status = res.statusCode ?? 0;
if (!_isSuccess(status)) {
throw _httpError(status, res.data);
}
final body = res.data ?? const <String, dynamic>{};
return ServerSyncSnapshot(blob: body['blob'] as String?);
} on DioException catch (e) {
throw _networkError(e);
}
}
@override
Future<void> push({
required String baseUrl,
required String accessToken,
required String blob,
}) async {
final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/server-sync';
try {
final res = await _dio.put<Map<String, dynamic>>(
url,
data: {'blob': blob},
options: Options(headers: {'Authorization': 'Bearer $accessToken'}),
);
final status = res.statusCode ?? 0;
if (!_isSuccess(status)) {
throw _httpError(status, res.data);
}
} on DioException catch (e) {
throw _networkError(e);
}
}
DomainError _httpError(int status, Map<String, dynamic>? body) {
final message = (body?['message'] as String?)?.trim();
AppLogger.warn(_tag, 'HTTP $status ${message ?? ''}');
return switch (status) {
401 || 403 => DomainError(
ErrorCode.authForbidden,
message ?? '账号登录已过期,请重新登录',
details: {'status': status},
),
413 => DomainError(
ErrorCode.invalidParams,
message ?? '同步数据过大(上限 256KB',
details: {'status': status},
),
400 => DomainError(
ErrorCode.invalidParams,
message ?? '请求无效',
details: {'status': status},
),
_ => DomainError(
ErrorCode.serverHttpError,
message ?? '同步失败(HTTP $status',
retryable: true,
details: {'status': status},
),
};
}
DomainError _networkError(DioException e) {
AppLogger.warn(_tag, 'network error', e);
return switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.sendTimeout => DomainError(
ErrorCode.serverTimeout,
'连接超时,请检查网络',
retryable: true,
),
_ => DomainError(
ErrorCode.serverUnreachable,
'无法连接服务器,请检查网络',
retryable: true,
),
};
}
static bool _isSuccess(int status) => status >= 200 && status < 300;
}
@@ -0,0 +1,214 @@
import 'package:dio/dio.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/url_utils.dart';
import '../../domain/ports/sm_account_gateway.dart';
class SmAccountClient implements SmAccountGateway {
static const _tag = 'SmAccount';
final Dio _dio;
SmAccountClient({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 12),
headers: {'Accept': 'application/json'},
validateStatus: (status) => status != null && status < 500,
),
);
Future<T> _guard<T>(
String op,
Future<T> Function() body, {
required T Function(String message) failure,
required String fallbackMessage,
}) async {
try {
return await body();
} on DioException catch (e) {
AppLogger.warn(_tag, '$op network error', e);
return failure(_networkHint(e));
} catch (e) {
AppLogger.warn(_tag, '$op unexpected error', e);
return failure(fallbackMessage);
}
}
@override
Future<SmLoginResult> login({
required String baseUrl,
required String email,
required String password,
}) => _guard('login', () async {
final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/login';
final res = await _dio.post<Map<String, dynamic>>(
url,
data: {'email': email, 'password': password},
);
return _sessionResultFrom(res, fallbackEmail: email);
}, failure: SmLoginResult.failure, fallbackMessage: '登录失败,请稍后重试');
@override
Future<SmActionResult> registerStart({
required String baseUrl,
required String email,
}) => _guard('registerStart', () async {
final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/register/start';
final res = await _dio.post<Map<String, dynamic>>(
url,
data: {'email': email},
);
return _actionResultFrom(res, 'registerStart');
}, failure: SmActionResult.failure, fallbackMessage: '发送验证码失败,请稍后重试');
@override
Future<SmLoginResult> registerVerify({
required String baseUrl,
required String email,
required String password,
required String code,
}) => _guard('registerVerify', () async {
final url =
'${stripTrailingSlash(baseUrl.trim())}/api/user/register/verify';
final res = await _dio.post<Map<String, dynamic>>(
url,
data: {'email': email, 'password': password, 'code': code},
);
return _sessionResultFrom(res, fallbackEmail: email);
}, failure: SmLoginResult.failure, fallbackMessage: '验证失败,请稍后重试');
@override
Future<SmActionResult> resendCode({
required String baseUrl,
required String email,
}) => _guard('resendCode', () async {
final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/resend-code';
final res = await _dio.post<Map<String, dynamic>>(
url,
data: {'email': email, 'purpose': 'register'},
);
return _actionResultFrom(res, 'resendCode');
}, failure: SmActionResult.failure, fallbackMessage: '重发验证码失败,请稍后重试');
@override
Future<SmTokenRefreshResult> refreshAccessToken({
required String baseUrl,
required String refreshToken,
}) => _guard('refreshAccessToken', () async {
final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/token/refresh';
final res = await _dio.post<Map<String, dynamic>>(
url,
data: {'refreshToken': refreshToken},
);
final status = res.statusCode ?? 0;
final body = res.data ?? const <String, dynamic>{};
if (_isSuccess(status) && body['ok'] == true) {
return SmTokenRefreshResult(
ok: true,
accessToken: (body['accessToken'] as String?) ?? '',
refreshToken: (body['refreshToken'] as String?) ?? refreshToken,
expiresIn: (body['expiresIn'] as num?)?.toInt() ?? 0,
isVip: body['isVip'] == true,
vipExpiresAt: (body['vipExpiresAt'] as num?)?.toInt(),
vipPermanent: body['vipPermanent'] == true,
hasVipInfo: body.containsKey('isVip'),
);
}
final message = (body['message'] as String?)?.trim();
AppLogger.warn(_tag, 'refreshAccessToken ← HTTP $status ${message ?? ''}');
return SmTokenRefreshResult.failure(
message ?? _statusHint(status),
invalidRefreshToken: status == 401 || status == 403,
);
}, failure: SmTokenRefreshResult.failure, fallbackMessage: 'Token 刷新失败,请稍后重试');
SmActionResult _actionResultFrom(
Response<Map<String, dynamic>> res,
String op,
) {
final body = res.data ?? const <String, dynamic>{};
if (_isSuccess(res.statusCode) && body['ok'] == true) {
return SmActionResult.success();
}
final message = (body['message'] as String?)?.trim();
AppLogger.warn(_tag, '$op ← HTTP ${res.statusCode} ${message ?? ''}');
return SmActionResult.failure(message ?? _statusHint(res.statusCode ?? 0));
}
SmLoginResult _sessionResultFrom(
Response<Map<String, dynamic>> res, {
required String fallbackEmail,
}) {
final status = res.statusCode ?? 0;
final body = res.data ?? const <String, dynamic>{};
final ok = body['ok'] == true;
if (_isSuccess(status) && ok) {
final refreshToken = (body['refreshToken'] as String?)?.trim() ?? '';
if (refreshToken.isEmpty) {
AppLogger.warn(_tag, 'auth ok but missing refresh token');
return SmLoginResult.failure('认证成功但未收到登录凭据,请稍后重试');
}
return SmLoginResult(
ok: true,
email: (body['email'] as String?)?.trim() ?? fallbackEmail.trim(),
accessToken: (body['accessToken'] as String?) ?? '',
refreshToken: refreshToken,
expiresIn: (body['expiresIn'] as num?)?.toInt() ?? 0,
isVip: body['isVip'] == true,
vipExpiresAt: (body['vipExpiresAt'] as num?)?.toInt(),
vipPermanent: body['vipPermanent'] == true,
);
}
final message = (body['message'] as String?)?.trim();
final needsVerification = body['needsVerification'] == true;
AppLogger.warn(_tag, 'auth ← HTTP $status ${message ?? ''}');
return SmLoginResult.failure(
message ?? _statusHint(status),
needsVerification: needsVerification,
);
}
@override
Future<void> logout({
required String baseUrl,
required String refreshToken,
}) async {
final token = refreshToken.trim();
if (token.isEmpty) return;
final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/token/logout';
try {
await _dio.post<Map<String, dynamic>>(url, data: {'refreshToken': token});
} catch (e) {
AppLogger.warn(_tag, 'logout failed (non-fatal)', e);
}
}
static bool _isSuccess(int? status) =>
status != null && status >= 200 && status < 300;
static String _statusHint(int status) => switch (status) {
401 => '邮箱或密码错误',
403 => '邮箱尚未验证',
429 => '请求过于频繁,请稍后再试',
_ => '请求失败(HTTP $status',
};
static String _networkHint(DioException e) => switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.sendTimeout => '连接超时,请检查网络或服务器地址',
DioExceptionType.connectionError => '无法连接服务器,请检查服务器地址',
_ => '网络异常,请稍后重试',
};
}
@@ -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>[];
}
}
}
@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter/services.dart';
class SystemVolumeEvent {
final int current;
final int max;
const SystemVolumeEvent({required this.current, required this.max});
double get percent => max == 0 ? 0 : current / max;
}
class SystemAudioController {
static const _methodChannel = MethodChannel('smplayer/system_audio');
static const _eventChannel = EventChannel('smplayer/system_audio_events');
SystemAudioController._();
static final SystemAudioController instance = SystemAudioController._();
Stream<SystemVolumeEvent>? _volumeChanges;
Stream<SystemVolumeEvent> get volumeChanges {
_volumeChanges ??= _eventChannel.receiveBroadcastStream().map((event) {
final map = (event as Map).cast<Object?, Object?>();
return SystemVolumeEvent(
current: (map['current'] as num).toInt(),
max: (map['max'] as num).toInt(),
);
});
return _volumeChanges!;
}
Future<SystemVolumeEvent> getVolume() async {
final result = await _methodChannel.invokeMapMethod<String, dynamic>(
'getVolume',
);
if (result == null) {
return const SystemVolumeEvent(current: 0, max: 15);
}
return SystemVolumeEvent(
current: (result['current'] as num).toInt(),
max: (result['max'] as num).toInt(),
);
}
Future<SystemVolumeEvent> adjustVolume(int delta) async {
final result = await _methodChannel.invokeMapMethod<String, dynamic>(
'adjustVolume',
{'delta': delta},
);
if (result == null) {
return const SystemVolumeEvent(current: 0, max: 15);
}
return SystemVolumeEvent(
current: (result['current'] as num).toInt(),
max: (result['max'] as num).toInt(),
);
}
Future<void> setKeyIntercept(bool enabled) async {
await _methodChannel.invokeMethod<void>('setKeyIntercept', {
'enabled': enabled,
});
}
}
+420
View File
@@ -0,0 +1,420 @@
import 'package:dio/dio.dart';
import '../../../shared/utils/app_logger.dart';
import '../../contracts/tmdb.dart';
import '../../domain/ports/tmdb_gateway.dart';
import 'tmdb_rest_api.dart';
class TmdbApiClient implements TmdbGateway {
static const _tag = 'Tmdb';
TmdbApiClient({Dio? dio}) : _api = _buildApi(dio);
static TmdbRestApi _buildApi(Dio? injected) {
final dio =
injected ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
headers: {'Accept': 'application/json'},
),
);
dio.interceptors.add(TmdbConfigInterceptor());
dio.interceptors.add(TmdbAuthRetryInterceptor(dio));
return TmdbRestApi(dio);
}
final TmdbRestApi _api;
Map<String, dynamic> _extras(TmdbRequestConfig config) => <String, dynamic>{
kTmdbConfigExtraKey: config,
};
Map<String, dynamic> _extrasWithLang(
TmdbRequestConfig config,
String language,
) => <String, dynamic>{
kTmdbConfigExtraKey: config,
kTmdbAcceptLanguageExtraKey: language,
};
@override
Future<TmdbCreditsRes?> getCredits(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
}) async {
final type = _normalizeMediaType(mediaType);
if (tmdbId.isEmpty || type == null) return null;
try {
return await _api.getCredits(type, tmdbId, language, _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /$type/$tmdbId/credits failed', e);
return null;
}
}
@override
Future<TmdbRecommendationsRes?> getRecommendations(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
}) async {
final type = _normalizeMediaType(mediaType);
if (tmdbId.isEmpty || type == null) return null;
try {
return await _api.getRecommendations(
type,
tmdbId,
language,
'1',
_extras(config),
);
} catch (e) {
AppLogger.warn(_tag, 'GET /$type/$tmdbId/recommendations failed', e);
return null;
}
}
@override
Future<TmdbSeasonDetail?> getSeasonDetail(
TmdbRequestConfig config,
String tmdbId,
int seasonNumber, {
required String language,
}) async {
if (tmdbId.isEmpty || seasonNumber < 0) return null;
try {
return await _api.getSeasonDetail(
tmdbId,
seasonNumber,
language,
_extras(config),
);
} catch (e) {
AppLogger.warn(_tag, 'GET /tv/$tmdbId/season/$seasonNumber failed', e);
return null;
}
}
@override
Future<TmdbImageSet?> getImages(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
}) async {
final type = _normalizeMediaType(mediaType);
if (tmdbId.isEmpty || type == null) return null;
try {
return await _api.getImages(
type,
tmdbId,
_includeImageLanguage(language),
_extrasWithLang(config, language),
);
} catch (e) {
AppLogger.warn(_tag, 'GET /$type/$tmdbId/images failed', e);
return null;
}
}
@override
Future<TmdbEnrichedDetail?> getEnrichedDetail(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
List<String> appendToResponse = const [
'videos',
'credits',
'recommendations',
'images',
'external_ids',
],
}) async {
final type = _normalizeMediaType(mediaType);
if (tmdbId.isEmpty || type == null) return null;
final TmdbEnrichedDetail detail;
try {
detail = await _api.getEnrichedDetail(
type,
tmdbId,
language,
appendToResponse.join(','),
appendToResponse.contains('images')
? _includeImageLanguage(language)
: null,
_extras(config),
);
} catch (e) {
AppLogger.warn(_tag, 'GET /$type/$tmdbId failed', e);
return null;
}
final shouldFallbackVideos =
appendToResponse.contains('videos') &&
!_isEnglishLanguage(language) &&
(detail.videos?.results.isEmpty ?? true);
if (!shouldFallbackVideos) return detail;
final fallbackVideos = await getVideos(
config,
tmdbId,
type,
language: 'en-US',
);
if (fallbackVideos == null || fallbackVideos.results.isEmpty) {
return detail;
}
return detail.copyWith(videos: fallbackVideos);
}
@override
Future<TmdbVideosRes?> getVideos(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
}) async {
final type = _normalizeMediaType(mediaType);
if (tmdbId.isEmpty || type == null) return null;
try {
return await _api.getVideos(type, tmdbId, language, _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /$type/$tmdbId/videos failed', e);
return null;
}
}
@override
Future<TmdbPersonDetail?> getPersonDetail(
TmdbRequestConfig config,
int personId, {
required String language,
}) async {
if (personId <= 0) return null;
try {
return await _api.getPersonDetail(personId, language, _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /person/$personId failed', e);
return null;
}
}
@override
Future<TmdbPersonCreditsRes?> getPersonCredits(
TmdbRequestConfig config,
int personId, {
required String language,
}) async {
if (personId <= 0) return null;
try {
return await _api.getPersonCredits(personId, language, _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /person/$personId/combined_credits failed', e);
return null;
}
}
@override
Future<TmdbMediaDetail?> getMediaDetail(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
}) async {
final type = _normalizeMediaType(mediaType);
if (tmdbId.isEmpty || type == null) return null;
try {
return await _api.getMediaDetail(type, tmdbId, language, _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /$type/$tmdbId failed', e);
return null;
}
}
@override
Future<TmdbRecommendationsRes?> getTrending(
TmdbRequestConfig config, {
required String language,
String mediaType = 'all',
String timeWindow = 'day',
}) async {
try {
return await _api.getTrending(
mediaType,
timeWindow,
language,
'1',
_extras(config),
);
} catch (e) {
AppLogger.warn(_tag, 'GET /trending/$mediaType/$timeWindow failed', e);
return null;
}
}
@override
Future<TmdbRecommendationsRes?> getPopularMovies(
TmdbRequestConfig config, {
required String language,
}) async {
try {
return await _api.getPopularMovies(language, '1', _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /movie/popular failed', e);
return null;
}
}
@override
Future<TmdbRecommendationsRes?> getPopularTv(
TmdbRequestConfig config, {
required String language,
}) async {
try {
return await _api.getPopularTv(language, '1', _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /tv/popular failed', e);
return null;
}
}
@override
Future<TmdbRecommendationsRes?> getAiringTodayTv(
TmdbRequestConfig config, {
required String language,
}) async {
try {
return await _api.getAiringTodayTv(language, '1', _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /tv/airing_today failed', e);
return null;
}
}
@override
Future<TmdbRecommendationsRes?> getTopRatedMovies(
TmdbRequestConfig config, {
required String language,
}) async {
try {
return await _api.getTopRatedMovies(language, '1', _extras(config));
} catch (e) {
AppLogger.warn(_tag, 'GET /movie/top_rated failed', e);
return null;
}
}
@override
Future<TmdbRecommendationsRes?> getDiscover(
TmdbRequestConfig config,
String mediaType, {
required String language,
String sortBy = 'popularity.desc',
String? withGenres,
String? voteCountGte,
}) async {
final type = _normalizeMediaType(mediaType);
if (type == null) return null;
try {
return await _api.getDiscover(
type,
language,
'1',
sortBy,
withGenres,
voteCountGte,
_extras(config),
);
} catch (e) {
AppLogger.warn(_tag, 'GET /discover/$type failed', e);
return null;
}
}
@override
Future<bool> testConnection(
TmdbRequestConfig config, {
required String language,
}) async {
final accessToken = config.accessToken.trim();
final apiKey = config.apiKey.trim();
final authMode = accessToken.isNotEmpty
? 'bearer(${_maskSecret(accessToken)})'
: apiKey.isNotEmpty
? 'apiKey(${_maskSecret(apiKey)})'
: 'none';
AppLogger.info(
_tag,
'testConnection → GET ${config.apiBaseUrl}/configuration '
'auth=$authMode lang=$language',
);
try {
final res = await _api.getConfiguration(
_extrasWithLang(config, language),
);
final data = res.data;
final ok =
data is Map && (data['images'] is Map || data['change_keys'] is List);
AppLogger.info(
_tag,
'testConnection ← ${res.response.statusCode} '
'ok=$ok (body=${data.runtimeType})',
);
return ok;
} on DioException catch (e) {
final status = e.response?.statusCode;
final hint = switch (status) {
401 => '认证失败:token/api_key 被拒绝,检查认证方式(Bearer/ApiKey)是否与凭据类型匹配',
403 => '权限不足:凭据有效但无访问权限',
404 => 'baseUrl 路径错误:检查 ${config.apiBaseUrl}',
null => '网络层失败(${e.type.name}):检查 baseUrl 可达性/代理',
_ => '',
};
AppLogger.warn(
_tag,
'testConnection ← HTTP $status auth=$authMode '
'url=${config.apiBaseUrl}/configuration'
'${hint.isEmpty ? '' : '$hint'}',
e,
);
return false;
} catch (e) {
AppLogger.warn(_tag, 'testConnection ← 非 Dio 异常 auth=$authMode', e);
return false;
}
}
static String _maskSecret(String secret) {
if (secret.length <= 4) return '****';
return 'len=${secret.length},…${secret.substring(secret.length - 4)}';
}
static String? _normalizeMediaType(String mediaType) {
return switch (mediaType) {
'movie' || 'tv' => mediaType,
_ => null,
};
}
static String _includeImageLanguage(String language) {
final imageLanguages = <String>[_languageCode(language), 'en', 'null'];
return imageLanguages.where((v) => v.isNotEmpty).toSet().join(',');
}
static bool _isEnglishLanguage(String language) {
return _languageCode(language) == 'en';
}
static String _languageCode(String language) {
return language.trim().split(RegExp('[-_]')).first.toLowerCase();
}
}
+363
View File
@@ -0,0 +1,363 @@
import 'package:dio/dio.dart';
import '../../contracts/tmdb.dart';
import '../../domain/ports/tmdb_gateway.dart';
const String kTmdbConfigExtraKey = 'tmdbConfig';
const String kTmdbAcceptLanguageExtraKey = 'tmdbAcceptLanguage';
const String kTmdbOverrideAccessTokenKey = 'tmdbOverrideAccessToken';
const String kTmdbAuthRetriedKey = 'tmdbAuthRetried';
class TmdbRestApi {
TmdbRestApi(this._dio);
final Dio _dio;
Future<TmdbCreditsRes> getCredits(
String mediaType,
String tmdbId,
String language,
Map<String, dynamic> extras,
) {
return _getJson(
'/$mediaType/$tmdbId/credits',
queryParameters: {'language': language},
extras: extras,
fromJson: TmdbCreditsRes.fromJson,
);
}
Future<TmdbRecommendationsRes> getRecommendations(
String mediaType,
String tmdbId,
String language,
String page,
Map<String, dynamic> extras,
) {
return _getJson(
'/$mediaType/$tmdbId/recommendations',
queryParameters: {'language': language, 'page': page},
extras: extras,
fromJson: TmdbRecommendationsRes.fromJson,
);
}
Future<TmdbSeasonDetail> getSeasonDetail(
String tmdbId,
int seasonNumber,
String language,
Map<String, dynamic> extras,
) {
return _getJson(
'/tv/$tmdbId/season/$seasonNumber',
queryParameters: {'language': language},
extras: extras,
fromJson: TmdbSeasonDetail.fromJson,
);
}
Future<TmdbImageSet> getImages(
String mediaType,
String tmdbId,
String includeImageLanguage,
Map<String, dynamic> extras,
) {
return _getJson(
'/$mediaType/$tmdbId/images',
queryParameters: {'include_image_language': includeImageLanguage},
extras: extras,
fromJson: TmdbImageSet.fromJson,
);
}
Future<TmdbEnrichedDetail> getEnrichedDetail(
String mediaType,
String tmdbId,
String language,
String appendToResponse,
String? includeImageLanguage,
Map<String, dynamic> extras,
) {
return _getJson(
'/$mediaType/$tmdbId',
queryParameters: {
'language': language,
'append_to_response': appendToResponse,
'include_image_language': includeImageLanguage,
},
extras: extras,
fromJson: TmdbEnrichedDetail.fromJson,
);
}
Future<TmdbMediaDetail> getMediaDetail(
String mediaType,
String tmdbId,
String language,
Map<String, dynamic> extras,
) {
return _getJson(
'/$mediaType/$tmdbId',
queryParameters: {'language': language},
extras: extras,
fromJson: TmdbMediaDetail.fromJson,
);
}
Future<TmdbVideosRes> getVideos(
String mediaType,
String tmdbId,
String language,
Map<String, dynamic> extras,
) {
return _getJson(
'/$mediaType/$tmdbId/videos',
queryParameters: {'language': language},
extras: extras,
fromJson: TmdbVideosRes.fromJson,
);
}
Future<TmdbPersonDetail> getPersonDetail(
int personId,
String language,
Map<String, dynamic> extras,
) {
return _getJson(
'/person/$personId',
queryParameters: {'language': language},
extras: extras,
fromJson: TmdbPersonDetail.fromJson,
);
}
Future<TmdbPersonCreditsRes> getPersonCredits(
int personId,
String language,
Map<String, dynamic> extras,
) {
return _getJson(
'/person/$personId/combined_credits',
queryParameters: {'language': language},
extras: extras,
fromJson: TmdbPersonCreditsRes.fromJson,
);
}
Future<TmdbRecommendationsRes> getTrending(
String mediaType,
String timeWindow,
String language,
String page,
Map<String, dynamic> extras,
) {
return _getJson(
'/trending/$mediaType/$timeWindow',
queryParameters: {'language': language, 'page': page},
extras: extras,
fromJson: TmdbRecommendationsRes.fromJson,
);
}
Future<TmdbRecommendationsRes> getPopularMovies(
String language,
String page,
Map<String, dynamic> extras,
) {
return _getJson(
'/movie/popular',
queryParameters: {'language': language, 'page': page},
extras: extras,
fromJson: TmdbRecommendationsRes.fromJson,
);
}
Future<TmdbRecommendationsRes> getPopularTv(
String language,
String page,
Map<String, dynamic> extras,
) {
return _getJson(
'/tv/popular',
queryParameters: {'language': language, 'page': page},
extras: extras,
fromJson: TmdbRecommendationsRes.fromJson,
);
}
Future<TmdbRecommendationsRes> getAiringTodayTv(
String language,
String page,
Map<String, dynamic> extras,
) {
return _getJson(
'/tv/airing_today',
queryParameters: {'language': language, 'page': page},
extras: extras,
fromJson: TmdbRecommendationsRes.fromJson,
);
}
Future<TmdbRecommendationsRes> getTopRatedMovies(
String language,
String page,
Map<String, dynamic> extras,
) {
return _getJson(
'/movie/top_rated',
queryParameters: {'language': language, 'page': page},
extras: extras,
fromJson: TmdbRecommendationsRes.fromJson,
);
}
Future<TmdbRecommendationsRes> getDiscover(
String mediaType,
String language,
String page,
String sortBy,
String? withGenres,
String? voteCountGte,
Map<String, dynamic> extras,
) {
return _getJson(
'/discover/$mediaType',
queryParameters: {
'language': language,
'page': page,
'sort_by': sortBy,
'with_genres': withGenres,
'vote_count.gte': voteCountGte,
},
extras: extras,
fromJson: TmdbRecommendationsRes.fromJson,
);
}
Future<TmdbRawResponse<dynamic>> getConfiguration(
Map<String, dynamic> extras,
) async {
final response = await _dio.get<dynamic>(
'/configuration',
options: Options(extra: extras),
);
return TmdbRawResponse(data: response.data, response: response);
}
Future<T> _getJson<T>(
String path, {
required Map<String, Object?> queryParameters,
required Map<String, dynamic> extras,
required T Function(Map<String, dynamic> json) fromJson,
}) async {
final response = await _dio.get<dynamic>(
path,
queryParameters: _withoutNullValues(queryParameters),
options: Options(extra: extras),
);
final data = response.data;
if (data is Map<String, dynamic>) return fromJson(data);
if (data is Map) return fromJson(Map<String, dynamic>.from(data));
throw FormatException(
'TMDB response is not a JSON object: ${data.runtimeType}',
);
}
Map<String, dynamic> _withoutNullValues(Map<String, Object?> values) {
return <String, dynamic>{
for (final entry in values.entries)
if (entry.value != null) entry.key: entry.value,
};
}
}
class TmdbRawResponse<T> {
const TmdbRawResponse({required this.data, required this.response});
final T data;
final Response<T> response;
}
class TmdbConfigInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final config = options.extra[kTmdbConfigExtraKey];
if (config is! TmdbRequestConfig) {
handler.next(options);
return;
}
options.baseUrl = config.apiBaseUrl;
final override = options.extra[kTmdbOverrideAccessTokenKey];
final accessToken = (override is String && override.trim().isNotEmpty)
? override.trim()
: config.accessToken.trim();
final apiKey = config.apiKey.trim();
if (accessToken.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $accessToken';
} else if (apiKey.isNotEmpty) {
options.queryParameters['api_key'] = apiKey;
}
final extraLang = options.extra[kTmdbAcceptLanguageExtraKey];
final queryLang = options.queryParameters['language'];
final lang = extraLang is String && extraLang.trim().isNotEmpty
? extraLang.trim()
: (queryLang is String && queryLang.trim().isNotEmpty
? queryLang.trim()
: null);
if (lang != null) {
options.headers['Accept-Language'] = '$lang,en,*';
}
handler.next(options);
}
}
class TmdbAuthRetryInterceptor extends Interceptor {
TmdbAuthRetryInterceptor(this._dio);
final Dio _dio;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final options = err.requestOptions;
final config = options.extra[kTmdbConfigExtraKey];
final refresh = config is TmdbRequestConfig ? config.onUnauthorized : null;
final alreadyRetried = options.extra[kTmdbAuthRetriedKey] == true;
if (err.response?.statusCode != 401 || refresh == null || alreadyRetried) {
handler.next(err);
return;
}
final newToken = await refresh();
if (newToken == null || newToken.trim().isEmpty) {
handler.next(err);
return;
}
options.extra[kTmdbOverrideAccessTokenKey] = newToken.trim();
options.extra[kTmdbAuthRetriedKey] = true;
try {
final response = await _dio.fetch<dynamic>(options);
handler.resolve(response);
} on DioException catch (e) {
handler.next(e);
}
}
}
@@ -0,0 +1,213 @@
import 'package:dio/dio.dart';
import '../../../shared/utils/app_logger.dart';
import '../../contracts/trakt/trakt_response.dart';
import '../../contracts/trakt/trakt_sync_job.dart';
import '../../domain/ports/authed_trakt_gateway.dart';
import 'trakt_auth.dart';
import 'trakt_credentials.dart';
import 'trakt_rest_api.dart' show TraktHeaderInterceptor;
const _tag = 'AuthedTrakt';
class AuthedTraktClient implements AuthedTraktGateway {
AuthedTraktClient({required this._auth, Dio? dio})
: _dio =
(dio ??
Dio(
BaseOptions(
baseUrl: TraktCredentials.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {'Accept': 'application/json'},
),
))
..interceptors.add(TraktHeaderInterceptor());
final TraktAuth _auth;
final Dio _dio;
@override
Future<bool> isConnected() => _auth.isConnected();
@override
Future<TraktScrobbleOutcome> scrobble(
TraktSyncJobType type,
Map<String, dynamic> body,
) {
return _post(_scrobblePath(type), body, allowRefreshRetry: true);
}
@override
Future<TraktScrobbleOutcome> syncHistory(
TraktSyncJobType type,
Map<String, dynamic> body,
) {
final path = type == TraktSyncJobType.historyRemove
? '/sync/history/remove'
: '/sync/history';
return _post(path, body, allowRefreshRetry: true);
}
@override
Future<List<Map<String, dynamic>>> fetchPlayback() =>
_getList('/sync/playback?extended=full');
@override
Future<List<Map<String, dynamic>>> fetchCalendarShows() =>
_getList('/calendars/my/shows/${_todayUtc()}/7');
@override
Future<List<Map<String, dynamic>>> fetchCalendarMovies() =>
_getList('/calendars/my/movies/${_todayUtc()}/7');
static String _todayUtc() {
final now = DateTime.now().toUtc();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}
Future<List<Map<String, dynamic>>> _getList(
String path, {
bool allowRefreshRetry = true,
}) async {
final token = await _auth.validAccessToken();
if (token == null) throw StateError('Trakt not connected');
try {
final res = await _dio.get<dynamic>(
path,
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (_) => true,
),
);
final status = res.statusCode ?? 0;
if (status == 401 && allowRefreshRetry) {
final refreshed = await _auth.forceRefresh();
if (refreshed == null) throw StateError('Trakt token refresh failed');
return _getList(path, allowRefreshRetry: false);
}
if (status < 200 || status >= 300) {
throw StateError('Trakt GET $path$status');
}
final data = res.data;
if (data is! List) return const [];
return data.whereType<Map<String, dynamic>>().toList();
} on DioException catch (e) {
AppLogger.debug(_tag, 'GET $path network error', e);
throw StateError('Trakt network error on GET $path: $e');
}
}
static String _scrobblePath(TraktSyncJobType type) {
switch (type) {
case TraktSyncJobType.scrobbleStart:
return '/scrobble/start';
case TraktSyncJobType.scrobblePause:
return '/scrobble/pause';
case TraktSyncJobType.scrobbleStop:
default:
return '/scrobble/stop';
}
}
Future<TraktScrobbleOutcome> _post(
String path,
Map<String, dynamic> body, {
required bool allowRefreshRetry,
}) async {
final token = await _auth.validAccessToken();
if (token == null) {
return const TraktScrobbleOutcome(TraktResponseClass.unauthorized);
}
try {
final res = await _dio.post<dynamic>(
path,
data: body,
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (_) => true,
),
);
final status = res.statusCode ?? 0;
final cls = classifyTraktStatus(status);
if (cls == TraktResponseClass.success) {
final data = res.data;
AppLogger.debug(
_tag,
'scrobble $path$status '
'sent=${_identitySummary(body)} '
'matched=${data is Map ? _identitySummary(data) : '?'} '
'action=${data is Map ? data['action'] : '?'} '
'trakt_progress=${data is Map ? data['progress'] : '?'}',
);
} else {
AppLogger.debug(_tag, 'scrobble $path$status (${cls.name})');
}
if (cls == TraktResponseClass.unauthorized && allowRefreshRetry) {
final refreshed = await _auth.forceRefresh();
if (refreshed != null) {
return _post(path, body, allowRefreshRetry: false);
}
return const TraktScrobbleOutcome(TraktResponseClass.unauthorized);
}
if (cls == TraktResponseClass.rateLimited) {
return TraktScrobbleOutcome(
cls,
retryAfter: _parseRetryAfter(res.headers.value('retry-after')),
);
}
return TraktScrobbleOutcome(cls);
} on DioException catch (e) {
final status = e.response?.statusCode;
if (status != null) {
final cls = classifyTraktStatus(status);
if (cls == TraktResponseClass.rateLimited) {
return TraktScrobbleOutcome(
cls,
retryAfter: _parseRetryAfter(
e.response?.headers.value('retry-after'),
),
);
}
return TraktScrobbleOutcome(cls);
}
AppLogger.debug(_tag, 'network error on $path → transient', e);
return const TraktScrobbleOutcome(TraktResponseClass.transient);
} catch (e) {
AppLogger.debug(_tag, 'unexpected error on $path → transient', e);
return const TraktScrobbleOutcome(TraktResponseClass.transient);
}
}
static String _identitySummary(Map<dynamic, dynamic> data) {
final movie = data['movie'];
if (movie is Map) return 'movie "${movie['title']}" ids=${movie['ids']}';
final show = data['show'];
final episode = data['episode'];
if (show is Map || episode is Map) {
final showTitle = show is Map ? show['title'] : null;
final showIds = show is Map ? show['ids'] : null;
final s = episode is Map ? episode['season'] : null;
final n = episode is Map ? episode['number'] : null;
final epIds = episode is Map ? episode['ids'] : null;
return 'episode "$showTitle" S${s}E$n showIds=$showIds epIds=$epIds';
}
return 'none';
}
static Duration? _parseRetryAfter(String? raw) {
if (raw == null || raw.isEmpty) return null;
final secs = int.tryParse(raw.trim());
if (secs == null || secs < 0) return null;
return Duration(seconds: secs);
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'dart:async';
import '../../../shared/utils/app_logger.dart';
import '../../contracts/trakt/trakt_token.dart';
import '../../domain/ports/trakt_gateway.dart';
import 'trakt_token_store.dart';
const _tag = 'TraktAuth';
class TraktAuth {
TraktAuth({
required this.store,
required this.gateway,
this.refreshBufferSeconds = 24 * 3600,
});
final TraktTokenStore store;
final TraktGateway gateway;
final int refreshBufferSeconds;
void Function()? onCleared;
Future<TraktToken>? _inflightRefresh;
int _generation = 0;
Future<bool> isConnected() async => (await store.read()) != null;
Future<String?> validAccessToken() async {
final token = await store.read();
if (token == null || token.accessToken.isEmpty) return null;
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expiresAt = token.createdAt + token.expiresIn;
final notExpiring =
token.expiresIn <= 0 || nowSec < expiresAt - refreshBufferSeconds;
if (notExpiring) return token.accessToken;
if (token.refreshToken.isEmpty) {
await _clear();
return null;
}
try {
final fresh = await _refreshSingleFlight(token.refreshToken);
return fresh.accessToken;
} on _TraktRefreshCancelled {
return null;
} catch (e) {
AppLogger.warn(_tag, 'refresh failed, clearing token', e);
await _clear();
return null;
}
}
Future<String?> forceRefresh() async {
final token = await store.read();
if (token == null || token.refreshToken.isEmpty) {
await _clear();
return null;
}
try {
final fresh = await _refreshSingleFlight(token.refreshToken);
return fresh.accessToken;
} on _TraktRefreshCancelled {
return null;
} catch (e) {
AppLogger.warn(_tag, 'force refresh failed, clearing token', e);
await _clear();
return null;
}
}
Future<void> persist(TraktToken token) => store.write(token);
Future<void> clear() => _clear();
Future<TraktToken> _refreshSingleFlight(String refreshToken) {
final existing = _inflightRefresh;
if (existing != null) return existing;
final generation = _generation;
final future = () async {
try {
final fresh = await gateway.refreshToken(refreshToken);
if (generation != _generation) throw const _TraktRefreshCancelled();
await store.write(fresh);
AppLogger.debug(_tag, 'token refreshed');
return fresh;
} finally {
_inflightRefresh = null;
}
}();
_inflightRefresh = future;
return future;
}
Future<void> _clear() async {
_generation++;
await store.clear();
onCleared?.call();
}
}
class _TraktRefreshCancelled implements Exception {
const _TraktRefreshCancelled();
}
@@ -0,0 +1,71 @@
import 'package:dio/dio.dart';
import '../../../shared/utils/app_logger.dart';
import '../../contracts/trakt/trakt_token.dart';
import '../../contracts/trakt/trakt_user.dart';
import '../../domain/ports/trakt_gateway.dart';
import 'trakt_credentials.dart';
import 'trakt_rest_api.dart';
class TraktApiClient implements TraktGateway {
static const _tag = 'Trakt';
TraktApiClient({Dio? dio})
: _api = TraktRestApi(
(dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {'Accept': 'application/json'},
),
))
..interceptors.add(TraktHeaderInterceptor()),
);
final TraktRestApi _api;
@override
Future<TraktToken> exchangeCode(String code) async {
try {
return await _api.exchangeToken(<String, dynamic>{
'client_id': TraktCredentials.clientId,
'client_secret': TraktCredentials.clientSecret,
'redirect_uri': TraktCredentials.redirectUri,
'code': code,
'grant_type': 'authorization_code',
});
} catch (e) {
AppLogger.warn(_tag, 'exchangeCode failed', e);
rethrow;
}
}
@override
Future<TraktToken> refreshToken(String refreshToken) async {
try {
return await _api.exchangeToken(<String, dynamic>{
'client_id': TraktCredentials.clientId,
'client_secret': TraktCredentials.clientSecret,
'redirect_uri': TraktCredentials.redirectUri,
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
});
} catch (e) {
AppLogger.warn(_tag, 'refreshToken failed', e);
rethrow;
}
}
@override
Future<TraktUser> getMe(String accessToken) async {
try {
final res = await _api.getUserSettings('Bearer $accessToken');
return res.user ?? const TraktUser();
} catch (e) {
AppLogger.warn(_tag, 'getMe failed', e);
rethrow;
}
}
}
@@ -0,0 +1,30 @@
abstract final class TraktCredentials {
const TraktCredentials._();
static const String clientId =
'YOUR_TRAKT_CLIENT_ID';
static const String clientSecret =
'YOUR_TRAKT_CLIENT_SECRET';
static const String redirectUri = 'smplayer://oauth-trakt';
static const String scheme = 'smplayer';
static const String host = 'oauth-trakt';
static const String apiBaseUrl = 'https://api.trakt.tv';
static const String authBaseUrl = 'https://trakt.tv';
static const String apiVersion = '2';
static Uri authorizeUrl(String state) =>
Uri.parse('$authBaseUrl/oauth/authorize').replace(
queryParameters: <String, String>{
'response_type': 'code',
'client_id': clientId,
'redirect_uri': redirectUri,
'state': state,
},
);
}
@@ -0,0 +1,108 @@
import '../../contracts/trakt/trakt_ids.dart';
import '../../contracts/trakt/trakt_scrobble_target.dart';
abstract final class TraktMediaMatcher {
const TraktMediaMatcher._();
static TraktScrobbleTarget? match({
required String? itemType,
required String itemName,
required int? productionYear,
required Map<String, String> providerIds,
Map<String, String> seriesProviderIds = const {},
String? seriesName,
int? seriesProductionYear,
int? parentIndexNumber,
int? itemIndexNumber,
}) {
switch (itemType) {
case 'Movie':
return _matchMovie(itemName, productionYear, providerIds);
case 'Episode':
return _matchEpisode(
ownIds: providerIds,
seriesIds: seriesProviderIds,
seriesName: seriesName,
seriesYear: seriesProductionYear,
season: parentIndexNumber,
number: itemIndexNumber,
);
default:
return null;
}
}
static TraktScrobbleTarget? _matchMovie(
String name,
int? year,
Map<String, String> providerIds,
) {
final ids = _idsFrom(providerIds);
final title = name.trim();
if (!ids.hasReliableId && title.isEmpty) return null;
return TraktScrobbleTarget.movie(title: title, year: year, ids: ids);
}
static TraktScrobbleTarget? _matchEpisode({
required Map<String, String> ownIds,
required Map<String, String> seriesIds,
required String? seriesName,
required int? seriesYear,
required int? season,
required int? number,
}) {
final episodeIds = _idsFrom(ownIds);
final showIds = _idsFrom(seriesIds);
final title = (seriesName ?? '').trim();
if (episodeIds.hasReliableId) {
return TraktScrobbleTarget.episode(
showTitle: title,
showYear: seriesYear,
showIds: showIds,
season: season,
number: number,
episodeIds: episodeIds,
);
}
final hasShowIdentity = showIds.hasReliableId || title.isNotEmpty;
if (hasShowIdentity && season != null && number != null) {
return TraktScrobbleTarget.episode(
showTitle: title,
showYear: seriesYear,
showIds: showIds,
season: season,
number: number,
episodeIds: episodeIds,
);
}
return null;
}
static TraktIds _idsFrom(Map<String, String> providerIds) {
if (providerIds.isEmpty) return const TraktIds();
final lower = <String, String>{};
for (final entry in providerIds.entries) {
final k = entry.key.trim().toLowerCase();
final v = entry.value.trim();
if (k.isNotEmpty && v.isNotEmpty) lower[k] = v;
}
return TraktIds(
trakt: _parseInt(lower['trakt']),
imdb: lower['imdb'],
tmdb: _parseInt(lower['tmdb']),
tvdb: _parseInt(lower['tvdb']),
slug: lower['slug'],
);
}
static int? _parseInt(String? raw) {
if (raw == null || raw.isEmpty) return null;
return int.tryParse(raw);
}
}
@@ -0,0 +1,295 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:uuid/uuid.dart';
import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/async_lock.dart';
import '../../contracts/trakt/trakt_response.dart';
import '../../contracts/trakt/trakt_sync_job.dart';
import '../../domain/ports/authed_trakt_gateway.dart';
import 'trakt_retry_policy.dart';
const _tag = 'TraktQueue';
class TraktPendingSyncQueue {
TraktPendingSyncQueue({
required this.store,
required this.sender,
required this.policy,
DateTime Function()? clock,
String Function()? idGenerator,
}) : _clock = clock ?? DateTime.now,
_idGenerator = idGenerator ?? (() => const Uuid().v4());
final JsonTraktSyncJobStore store;
final AuthedTraktGateway sender;
final TraktRetryPolicy policy;
final DateTime Function() _clock;
final String Function() _idGenerator;
final _lock = AsyncLock();
bool _draining = false;
Future<void> enqueue({
required TraktSyncJobType type,
required Map<String, dynamic> body,
Duration? retryAfter,
}) async {
await _lock.run(() async {
final now = _clock();
final jobs = await store.load();
final nextJobs = _discardSupersededJobs(jobs, type: type, body: body);
final job = TraktSyncJob(
id: _idGenerator(),
type: type,
body: body,
nextRetryAt: retryAfter == null ? null : now.add(retryAfter),
);
nextJobs.add(job);
await store.save(nextJobs);
AppLogger.debug(_tag, 'enqueue ${type.wire} (total=${nextJobs.length})');
});
}
Future<void> discardSupersededBy({
required TraktSyncJobType type,
required Map<String, dynamic> body,
}) async {
await _lock.run(() async {
final jobs = await store.load();
final nextJobs = _discardSupersededJobs(jobs, type: type, body: body);
if (nextJobs.length != jobs.length) {
await store.save(nextJobs);
}
});
}
Future<int> pendingCount() async {
final jobs = await _lock.run(store.load);
return jobs.where((j) => !j.exhausted).length;
}
Future<void> drain() async {
if (_draining) return;
_draining = true;
try {
await _lock.run(_drainLocked);
} finally {
_draining = false;
}
}
Future<void> _drainLocked() async {
final jobs = await store.load();
if (jobs.isEmpty) return;
bool connected;
try {
connected = await sender.isConnected();
} catch (_) {
connected = false;
}
if (!connected) {
AppLogger.debug(
_tag,
'drain skipped: not connected (${jobs.length} pending)',
);
return;
}
final now = _clock();
final remaining = <TraktSyncJob>[];
var changed = false;
for (final job in jobs) {
if (job.exhausted) {
remaining.add(job);
continue;
}
final due = job.nextRetryAt == null || !job.nextRetryAt!.isAfter(now);
if (!due) {
remaining.add(job);
continue;
}
changed = true;
TraktScrobbleOutcome outcome;
try {
outcome = job.type.isHistory
? await sender.syncHistory(job.type, job.body)
: await sender.scrobble(job.type, job.body);
} catch (e) {
outcome = const TraktScrobbleOutcome(TraktResponseClass.transient);
AppLogger.debug(_tag, 'send threw, treat as transient', e);
}
final decision = policy.evaluate(
cls: outcome.cls,
retryCount: job.retryCount,
retryAfter: outcome.retryAfter,
);
switch (decision.decision) {
case TraktRetryDecision.complete:
AppLogger.debug(_tag, 'job ${job.id} ${job.type.wire} → complete');
break;
case TraktRetryDecision.discard:
AppLogger.debug(
_tag,
'job ${job.id} ${job.type.wire} → discard '
'(${outcome.cls.name})',
);
break;
case TraktRetryDecision.retry:
remaining.add(
job.copyWith(
retryCount: job.retryCount + 1,
nextRetryAt: now.add(decision.delay ?? policy.baseBackoff),
),
);
AppLogger.debug(
_tag,
'job ${job.id} → retry in '
'${(decision.delay ?? policy.baseBackoff).inSeconds}s '
'(count=${job.retryCount + 1})',
);
break;
case TraktRetryDecision.giveUp:
remaining.add(job.copyWith(clearNextRetryAt: true, exhausted: true));
AppLogger.warn(
_tag,
'job ${job.id} ${job.type.wire} → giveUp '
'(exhausted after ${job.retryCount} retries)',
);
break;
}
}
if (changed) await store.save(remaining);
}
List<TraktSyncJob> _discardSupersededJobs(
List<TraktSyncJob> jobs, {
required TraktSyncJobType type,
required Map<String, dynamic> body,
}) {
final key = _mediaKey(body);
if (key == null) return List.of(jobs);
if (type.isHistory) {
return jobs
.where((job) {
if (job.exhausted) return true;
if (!job.type.isHistory) return true;
return _mediaKey(job.body) != key;
})
.toList(growable: true);
}
if (type != TraktSyncJobType.scrobbleStop) return List.of(jobs);
return jobs
.where((job) {
if (job.exhausted) return true;
if (job.type == TraktSyncJobType.scrobbleStop) return true;
return _mediaKey(job.body) != key;
})
.toList(growable: true);
}
static String? _mediaKey(Map<String, dynamic> body) {
final movie = body['movie'];
if (movie is Map) {
return 'movie:${jsonEncode(_canonical(movie.cast<String, dynamic>()))}';
}
final movies = body['movies'];
if (movies is List && movies.isNotEmpty) {
final first = movies.first;
if (first is Map) {
return 'movie:${jsonEncode(_canonical(first, skipWatchedAt: true))}';
}
}
final show = body['show'];
final episode = body['episode'];
if (show is Map && episode is Map) {
final identity = {
'show': _canonical(show.cast<String, dynamic>()),
'episode': _canonical(episode.cast<String, dynamic>()),
};
return 'episode:${jsonEncode(identity)}';
}
final shows = body['shows'];
if (shows is List && shows.isNotEmpty) {
final first = shows.first;
if (first is Map) {
return 'show:${jsonEncode(_canonical(first, skipWatchedAt: true))}';
}
}
return null;
}
static Object? _canonical(Object? value, {bool skipWatchedAt = false}) {
if (value is Map) {
var filtered = value.entries;
if (skipWatchedAt) {
filtered = filtered.where((e) => e.key.toString() != 'watched_at');
}
final entries =
filtered
.map(
(e) => MapEntry(
e.key.toString(),
_canonical(e.value, skipWatchedAt: skipWatchedAt),
),
)
.toList()
..sort((a, b) => a.key.compareTo(b.key));
return <String, Object?>{
for (final entry in entries) entry.key: entry.value,
};
}
if (value is List) {
return value
.map((e) => _canonical(e, skipWatchedAt: skipWatchedAt))
.toList(growable: false);
}
return value;
}
}
class JsonTraktSyncJobStore {
JsonTraktSyncJobStore({required this._file});
final File _file;
Future<List<TraktSyncJob>> load() async {
if (!await _file.exists()) return <TraktSyncJob>[];
final raw = await _file.readAsString();
if (raw.trim().isEmpty) return <TraktSyncJob>[];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.whereType<Map<String, dynamic>>()
.map(TraktSyncJob.fromJson)
.toList();
} catch (_) {
return <TraktSyncJob>[];
}
}
Future<void> save(List<TraktSyncJob> jobs) async {
if (!await _file.parent.exists()) {
await _file.parent.create(recursive: true);
}
await _file.writeAsString(
const JsonEncoder.withIndent(
' ',
).convert(jobs.map((e) => e.toJson()).toList()),
flush: true,
);
}
}
@@ -0,0 +1,50 @@
import 'package:dio/dio.dart';
import '../../contracts/trakt/trakt_token.dart';
import '../../contracts/trakt/trakt_user.dart';
import 'trakt_credentials.dart';
class TraktRestApi {
TraktRestApi(this._dio) {
_dio.options.baseUrl = _dio.options.baseUrl.isEmpty
? TraktCredentials.apiBaseUrl
: _dio.options.baseUrl;
}
final Dio _dio;
Future<TraktToken> exchangeToken(Map<String, dynamic> body) async {
final response = await _dio.post<dynamic>('/oauth/token', data: body);
return TraktToken.fromJson(_jsonObject(response.data));
}
Future<TraktUserSettings> getUserSettings(String authorization) async {
final response = await _dio.get<dynamic>(
'/users/settings',
options: Options(headers: {'Authorization': authorization}),
);
return TraktUserSettings.fromJson(_jsonObject(response.data));
}
Map<String, dynamic> _jsonObject(dynamic data) {
if (data is Map<String, dynamic>) return data;
if (data is Map) return Map<String, dynamic>.from(data);
throw FormatException(
'Trakt response is not a JSON object: ${data.runtimeType}',
);
}
}
class TraktHeaderInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers['trakt-api-key'] = TraktCredentials.clientId;
options.headers['trakt-api-version'] = TraktCredentials.apiVersion;
options.headers['Content-Type'] = 'application/json';
handler.next(options);
}
}
@@ -0,0 +1,74 @@
import '../../contracts/trakt/trakt_response.dart';
enum TraktRetryDecision {
complete,
discard,
retry,
giveUp,
}
class TraktRetryOutcome {
final TraktRetryDecision decision;
final Duration? delay;
const TraktRetryOutcome(this.decision, {this.delay});
}
class TraktRetryPolicy {
TraktRetryPolicy({
this.maxRetries = 8,
this.baseBackoff = const Duration(seconds: 5),
this.maxBackoff = const Duration(minutes: 30),
});
final int maxRetries;
final Duration baseBackoff;
final Duration maxBackoff;
TraktRetryOutcome evaluate({
required TraktResponseClass cls,
required int retryCount,
Duration? retryAfter,
}) {
switch (cls) {
case TraktResponseClass.success:
case TraktResponseClass.duplicate:
return const TraktRetryOutcome(TraktRetryDecision.complete);
case TraktResponseClass.invalid:
return const TraktRetryOutcome(TraktRetryDecision.discard);
case TraktResponseClass.rateLimited:
if (retryCount >= maxRetries) {
return const TraktRetryOutcome(TraktRetryDecision.giveUp);
}
return TraktRetryOutcome(
TraktRetryDecision.retry,
delay: retryAfter ?? backoff(retryCount),
);
case TraktResponseClass.unauthorized:
case TraktResponseClass.transient:
if (retryCount >= maxRetries) {
return const TraktRetryOutcome(TraktRetryDecision.giveUp);
}
return TraktRetryOutcome(
TraktRetryDecision.retry,
delay: backoff(retryCount),
);
}
}
Duration backoff(int retryCount) {
final shift = retryCount.clamp(0, 20);
final millis = baseBackoff.inMilliseconds * (1 << shift);
if (millis >= maxBackoff.inMilliseconds || millis < 0) return maxBackoff;
return Duration(milliseconds: millis);
}
}
@@ -0,0 +1,236 @@
import '../../../shared/utils/app_logger.dart';
import '../../contracts/library.dart';
import '../../contracts/trakt/trakt_ids.dart';
import '../../contracts/trakt/trakt_response.dart';
import '../../contracts/trakt/trakt_scrobble_target.dart';
import '../../contracts/trakt/trakt_sync_job.dart';
import '../../domain/ports/authed_trakt_gateway.dart';
import 'trakt_media_matcher.dart';
import 'trakt_pending_sync_queue.dart';
const _tag = 'TraktSync';
class TraktSyncService {
TraktSyncService({required this._traktGateway, required this._queue});
final AuthedTraktGateway _traktGateway;
final TraktPendingSyncQueue _queue;
Future<void> syncEmbyPlayedChange({
required EmbyRawItem item,
required bool played,
EmbyRawItem? seriesItem,
}) async {
try {
final target = TraktMediaMatcher.match(
itemType: item.Type,
itemName: item.Name,
productionYear: item.ProductionYear,
providerIds: providerIdsOfItem(item),
seriesProviderIds: seriesItem == null
? const {}
: providerIdsOfItem(seriesItem),
seriesName: item.SeriesName ?? seriesItem?.Name,
seriesProductionYear: seriesItem?.ProductionYear,
parentIndexNumber: (item.extra['ParentIndexNumber'] as num?)?.toInt(),
itemIndexNumber: item.IndexNumber,
);
if (target == null) return;
switch (target.kind) {
case TraktMediaKind.movie:
if (played) {
await markWatched(
ids: target.ids,
title: target.title,
year: target.year,
);
} else {
await markUnwatched(
ids: target.ids,
title: target.title,
year: target.year,
);
}
case TraktMediaKind.episode:
if (played) {
await markWatched(
ids: target.episodeIds,
title: item.Name,
year: item.ProductionYear,
season: target.season,
episode: target.number,
showIds: target.ids,
showTitle: target.title,
showYear: target.year,
);
} else {
await markUnwatched(
ids: target.episodeIds,
title: item.Name,
year: item.ProductionYear,
season: target.season,
episode: target.number,
showIds: target.ids,
showTitle: target.title,
showYear: target.year,
);
}
}
} catch (e) {
AppLogger.warn(_tag, 'syncEmbyPlayedChange failed', e);
}
}
Future<void> markWatched({
required TraktIds ids,
required String title,
required int? year,
DateTime? watchedAt,
int? season,
int? episode,
TraktIds? showIds,
String? showTitle,
int? showYear,
}) async {
final body = _buildHistoryBody(
ids: ids,
title: title,
year: year,
watchedAt: watchedAt ?? DateTime.now().toUtc(),
season: season,
episode: episode,
showIds: showIds,
showTitle: showTitle,
showYear: showYear,
);
AppLogger.debug(_tag, 'markWatched: $title');
await _sendHistory(TraktSyncJobType.historyAdd, body, label: 'markWatched');
}
Future<void> markUnwatched({
required TraktIds ids,
required String title,
required int? year,
int? season,
int? episode,
TraktIds? showIds,
String? showTitle,
int? showYear,
}) async {
final body = _buildHistoryBody(
ids: ids,
title: title,
year: year,
watchedAt: null,
season: season,
episode: episode,
showIds: showIds,
showTitle: showTitle,
showYear: showYear,
);
AppLogger.debug(_tag, 'markUnwatched: $title');
await _sendHistory(
TraktSyncJobType.historyRemove,
body,
label: 'markUnwatched',
);
}
Future<void> _sendHistory(
TraktSyncJobType type,
Map<String, dynamic> body, {
required String label,
}) async {
TraktScrobbleOutcome outcome;
try {
outcome = await _traktGateway.syncHistory(type, body);
} catch (e) {
AppLogger.debug(_tag, '$label threw; enqueue', e);
await _enqueue(type, body);
return;
}
switch (outcome.cls) {
case TraktResponseClass.success:
case TraktResponseClass.duplicate:
AppLogger.debug(_tag, '$label ok (${outcome.cls.name})');
return;
case TraktResponseClass.invalid:
AppLogger.debug(_tag, '$label invalid → drop');
return;
case TraktResponseClass.rateLimited:
case TraktResponseClass.unauthorized:
case TraktResponseClass.transient:
await _enqueue(type, body, retryAfter: outcome.retryAfter);
}
}
Map<String, dynamic> _buildHistoryBody({
required TraktIds ids,
required String title,
required int? year,
required DateTime? watchedAt,
int? season,
int? episode,
TraktIds? showIds,
String? showTitle,
int? showYear,
}) {
final isEpisode = season != null && episode != null;
if (!isEpisode) {
final movie = <String, dynamic>{'title': title, 'ids': ids.toJson()};
if (year != null) movie['year'] = year;
if (watchedAt != null) movie['watched_at'] = watchedAt.toIso8601String();
return {
'movies': [movie],
'shows': [],
'episodes': [],
};
}
final show = <String, dynamic>{
'title': showTitle ?? title,
'ids': (showIds ?? const TraktIds()).toJson(),
};
if (showYear != null) show['year'] = showYear;
final epNode = <String, dynamic>{'number': episode};
if (watchedAt != null) epNode['watched_at'] = watchedAt.toIso8601String();
show['seasons'] = [
{
'number': season,
'episodes': [epNode],
},
];
return {
'movies': [],
'shows': [show],
'episodes': [],
};
}
Future<void> _enqueue(
TraktSyncJobType type,
Map<String, dynamic> body, {
Duration? retryAfter,
}) async {
try {
await _queue.enqueue(type: type, body: body, retryAfter: retryAfter);
} catch (e) {
AppLogger.warn(_tag, 'enqueue failed', e);
}
}
}
@@ -0,0 +1,46 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../../contracts/trakt/trakt_token.dart';
class TraktTokenStore {
static const keyAccessToken = 'smplayer.trakt_access_token';
static const keyRefreshToken = 'smplayer.trakt_refresh_token';
static const keyExpiresIn = 'smplayer.trakt_expires_in';
static const keyCreatedAt = 'smplayer.trakt_created_at';
Future<SharedPreferences> _prefs() => SharedPreferences.getInstance();
Future<TraktToken?> read() async {
final p = await _prefs();
final access = p.getString(keyAccessToken)?.trim() ?? '';
if (access.isEmpty) return null;
return TraktToken(
accessToken: access,
refreshToken: p.getString(keyRefreshToken)?.trim() ?? '',
expiresIn: p.getInt(keyExpiresIn) ?? 0,
createdAt: p.getInt(keyCreatedAt) ?? 0,
);
}
Future<void> write(TraktToken token) async {
final p = await _prefs();
await Future.wait<Object?>([
p.setString(keyAccessToken, token.accessToken),
p.setString(keyRefreshToken, token.refreshToken),
p.setInt(keyExpiresIn, token.expiresIn),
p.setInt(keyCreatedAt, token.createdAt),
]);
}
Future<void> clear() async {
final p = await _prefs();
await Future.wait<Object?>([
p.remove(keyAccessToken),
p.remove(keyRefreshToken),
p.remove(keyExpiresIn),
p.remove(keyCreatedAt),
]);
}
}