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