Initial commit
This commit is contained in:
+127
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, TargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import 'features/update/update_dialog.dart';
|
||||
import 'providers/appearance_provider.dart';
|
||||
import 'providers/di_providers.dart';
|
||||
import 'providers/server_sync_provider.dart';
|
||||
import 'providers/sm_account_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'router/app_router.dart' show appRouterProvider, rootNavigatorKey;
|
||||
import 'shared/theme/app_scroll_behavior.dart';
|
||||
import 'shared/theme/app_theme.dart';
|
||||
import 'shared/utils/app_logger.dart';
|
||||
|
||||
class SmPlayerApp extends ConsumerStatefulWidget {
|
||||
const SmPlayerApp({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SmPlayerApp> createState() => _SmPlayerAppState();
|
||||
}
|
||||
|
||||
class _SmPlayerAppState extends ConsumerState<SmPlayerApp> {
|
||||
bool _updateDialogShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
final queue = await ref.read(traktSyncQueueProvider.future);
|
||||
await queue.drain();
|
||||
} catch (e) {
|
||||
AppLogger.debug('Trakt', 'startup drain skipped', e);
|
||||
}
|
||||
unawaited(ref.read(updateProvider.notifier).silentCheck());
|
||||
unawaited(_autoSyncPull());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _autoSyncPull() async {
|
||||
try {
|
||||
final account = await ref.read(smAccountProvider.future);
|
||||
if (!account.hasVip) return;
|
||||
final outcome = await ref.read(serverSyncProvider.notifier).pull();
|
||||
if (outcome.isError) {
|
||||
AppLogger.debug('CloudSync', 'auto-pull failed: ${outcome.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.debug('CloudSync', 'auto-pull skipped', e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
final appearance = ref.watch(appearanceProvider);
|
||||
final flutterMode = appearance.maybeWhen(
|
||||
data: (d) => d.themeMode,
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'smPlayer',
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: const Locale('zh', 'CN'),
|
||||
supportedLocales: const [Locale('zh', 'CN'), Locale('en')],
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: flutterMode,
|
||||
scrollBehavior: const AppScrollBehavior(),
|
||||
routerConfig: router,
|
||||
builder: (context, child) {
|
||||
final zinc = Theme.of(context).brightness == Brightness.dark
|
||||
? FThemes.zinc.dark
|
||||
: FThemes.zinc.light;
|
||||
return FTheme(
|
||||
data: _isTouchPlatform ? zinc.touch : zinc.desktop,
|
||||
child: FToaster(
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
ref.listen<UpdateState>(updateProvider, (previous, next) {
|
||||
if (_updateDialogShown ||
|
||||
next.status != UpdateStatus.available ||
|
||||
!next.silent) {
|
||||
return;
|
||||
}
|
||||
_updateDialogShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final navContext = rootNavigatorKey.currentContext;
|
||||
if (navContext == null) return;
|
||||
showFDialog(
|
||||
context: navContext,
|
||||
builder: (_, _, _) => const UpdateDialog(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return child ?? const SizedBox();
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool get _isTouchPlatform => switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android ||
|
||||
TargetPlatform.iOS ||
|
||||
TargetPlatform.fuchsia => true,
|
||||
_ => false,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
class AppUpdateAsset {
|
||||
final String name;
|
||||
final String downloadUrl;
|
||||
final int size;
|
||||
final String? sha256;
|
||||
|
||||
|
||||
final String? version;
|
||||
|
||||
const AppUpdateAsset({
|
||||
required this.name,
|
||||
required this.downloadUrl,
|
||||
required this.size,
|
||||
this.sha256,
|
||||
this.version,
|
||||
});
|
||||
|
||||
factory AppUpdateAsset.fromJson(Map<String, dynamic> json) {
|
||||
return AppUpdateAsset(
|
||||
name: json['name']?.toString() ?? '',
|
||||
downloadUrl:
|
||||
json['browser_download_url']?.toString() ??
|
||||
json['browserDownloadUrl']?.toString() ??
|
||||
json['url']?.toString() ??
|
||||
'',
|
||||
size: json['size'] is int ? json['size'] as int : 0,
|
||||
sha256: (json['sha256']?.toString().trim().isEmpty ?? true)
|
||||
? null
|
||||
: json['sha256'].toString().trim().toLowerCase(),
|
||||
version: (json['version']?.toString().trim().isEmpty ?? true)
|
||||
? null
|
||||
: json['version'].toString().trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppUpdateInfo {
|
||||
final String version;
|
||||
final String releaseNotes;
|
||||
final Uri htmlUrl;
|
||||
final List<AppUpdateAsset> assets;
|
||||
final Map<String, AppUpdateAsset> platformAssets;
|
||||
final AppUpdateAsset? selectedAsset;
|
||||
final bool noAssetForPlatform;
|
||||
|
||||
const AppUpdateInfo({
|
||||
required this.version,
|
||||
required this.releaseNotes,
|
||||
required this.htmlUrl,
|
||||
required this.assets,
|
||||
this.platformAssets = const {},
|
||||
this.selectedAsset,
|
||||
this.noAssetForPlatform = false,
|
||||
});
|
||||
|
||||
AppUpdateInfo copyWith({
|
||||
String? version,
|
||||
String? releaseNotes,
|
||||
Uri? htmlUrl,
|
||||
List<AppUpdateAsset>? assets,
|
||||
Map<String, AppUpdateAsset>? platformAssets,
|
||||
AppUpdateAsset? selectedAsset,
|
||||
bool? noAssetForPlatform,
|
||||
}) {
|
||||
return AppUpdateInfo(
|
||||
version: version ?? this.version,
|
||||
releaseNotes: releaseNotes ?? this.releaseNotes,
|
||||
htmlUrl: htmlUrl ?? this.htmlUrl,
|
||||
assets: assets ?? this.assets,
|
||||
platformAssets: platformAssets ?? this.platformAssets,
|
||||
selectedAsset: selectedAsset ?? this.selectedAsset,
|
||||
noAssetForPlatform: noAssetForPlatform ?? this.noAssetForPlatform,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateCheckResult {
|
||||
final bool hasUpdate;
|
||||
final AppUpdateInfo? info;
|
||||
|
||||
const UpdateCheckResult({required this.hasUpdate, this.info});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth.freezed.dart';
|
||||
part 'auth.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class AuthByNameReq with _$AuthByNameReq {
|
||||
const factory AuthByNameReq({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) = _AuthByNameReq;
|
||||
|
||||
factory AuthByNameReq.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthByNameReqFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SessionData with _$SessionData {
|
||||
const factory SessionData({
|
||||
required String serverId,
|
||||
required String token,
|
||||
required String userId,
|
||||
required String userName,
|
||||
@JsonKey(includeIfNull: false) String? password,
|
||||
required String createdAt,
|
||||
}) = _SessionData;
|
||||
|
||||
factory SessionData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SessionDataFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class AuthedSession with _$AuthedSession {
|
||||
const factory AuthedSession({
|
||||
required String serverId,
|
||||
required String serverName,
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
required String userId,
|
||||
required String userName,
|
||||
@JsonKey(includeIfNull: false) String? password,
|
||||
required bool isActive,
|
||||
}) = _AuthedSession;
|
||||
|
||||
factory AuthedSession.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthedSessionFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SessionListRes with _$SessionListRes {
|
||||
const factory SessionListRes({
|
||||
required List<AuthedSession> sessions,
|
||||
|
||||
|
||||
String? activeServerId,
|
||||
}) = _SessionListRes;
|
||||
|
||||
factory SessionListRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$SessionListResFromJson(json);
|
||||
}
|
||||
Generated
+1119
File diff suppressed because it is too large
Load Diff
Generated
+78
@@ -0,0 +1,78 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_AuthByNameReq _$AuthByNameReqFromJson(Map<String, dynamic> json) =>
|
||||
_AuthByNameReq(
|
||||
serverId: json['serverId'] as String,
|
||||
username: json['username'] as String,
|
||||
password: json['password'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AuthByNameReqToJson(_AuthByNameReq instance) =>
|
||||
<String, dynamic>{
|
||||
'serverId': instance.serverId,
|
||||
'username': instance.username,
|
||||
'password': instance.password,
|
||||
};
|
||||
|
||||
_SessionData _$SessionDataFromJson(Map<String, dynamic> json) => _SessionData(
|
||||
serverId: json['serverId'] as String,
|
||||
token: json['token'] as String,
|
||||
userId: json['userId'] as String,
|
||||
userName: json['userName'] as String,
|
||||
password: json['password'] as String?,
|
||||
createdAt: json['createdAt'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SessionDataToJson(_SessionData instance) =>
|
||||
<String, dynamic>{
|
||||
'serverId': instance.serverId,
|
||||
'token': instance.token,
|
||||
'userId': instance.userId,
|
||||
'userName': instance.userName,
|
||||
'password': ?instance.password,
|
||||
'createdAt': instance.createdAt,
|
||||
};
|
||||
|
||||
_AuthedSession _$AuthedSessionFromJson(Map<String, dynamic> json) =>
|
||||
_AuthedSession(
|
||||
serverId: json['serverId'] as String,
|
||||
serverName: json['serverName'] as String,
|
||||
serverUrl: json['serverUrl'] as String,
|
||||
token: json['token'] as String,
|
||||
userId: json['userId'] as String,
|
||||
userName: json['userName'] as String,
|
||||
password: json['password'] as String?,
|
||||
isActive: json['isActive'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AuthedSessionToJson(_AuthedSession instance) =>
|
||||
<String, dynamic>{
|
||||
'serverId': instance.serverId,
|
||||
'serverName': instance.serverName,
|
||||
'serverUrl': instance.serverUrl,
|
||||
'token': instance.token,
|
||||
'userId': instance.userId,
|
||||
'userName': instance.userName,
|
||||
'password': ?instance.password,
|
||||
'isActive': instance.isActive,
|
||||
};
|
||||
|
||||
_SessionListRes _$SessionListResFromJson(Map<String, dynamic> json) =>
|
||||
_SessionListRes(
|
||||
sessions: (json['sessions'] as List<dynamic>)
|
||||
.map((e) => AuthedSession.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
activeServerId: json['activeServerId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SessionListResToJson(_SessionListRes instance) =>
|
||||
<String, dynamic>{
|
||||
'sessions': instance.sessions,
|
||||
'activeServerId': instance.activeServerId,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'auth.dart';
|
||||
import 'danmaku.dart';
|
||||
import 'script_widget.dart';
|
||||
import 'server.dart';
|
||||
|
||||
part 'backup.freezed.dart';
|
||||
part 'backup.g.dart';
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class BackupBundle with _$BackupBundle {
|
||||
const factory BackupBundle({
|
||||
required int version,
|
||||
required String exportedAt,
|
||||
required List<EmbyServer> servers,
|
||||
required List<SessionData> sessions,
|
||||
@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson)
|
||||
@Default(<DanmakuSource>[])
|
||||
List<DanmakuSource> danmakuSources,
|
||||
@Default(<InstalledScriptWidget>[])
|
||||
List<InstalledScriptWidget> scriptWidgets,
|
||||
@Default(<ScriptWidgetSubscription>[])
|
||||
List<ScriptWidgetSubscription> scriptWidgetSubscriptions,
|
||||
}) = _BackupBundle;
|
||||
|
||||
factory BackupBundle.fromJson(Map<String, dynamic> json) =>
|
||||
_$BackupBundleFromJson(json);
|
||||
}
|
||||
|
||||
List<DanmakuSource> _danmakuSourcesFromJson(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
final out = <DanmakuSource>[];
|
||||
for (final item in raw) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final source = DanmakuSource.fromJson(item);
|
||||
if (source.url.isNotEmpty) out.add(source);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _danmakuSourcesToJson(List<DanmakuSource> sources) {
|
||||
return [
|
||||
for (final s in sources)
|
||||
if (s.url.trim().isNotEmpty) {'url': s.url.trim(), 'name': s.name},
|
||||
];
|
||||
}
|
||||
Generated
+325
@@ -0,0 +1,325 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'backup.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$BackupBundle {
|
||||
|
||||
int get version; String get exportedAt; List<EmbyServer> get servers; List<SessionData> get sessions;@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List<DanmakuSource> get danmakuSources; List<InstalledScriptWidget> get scriptWidgets; List<ScriptWidgetSubscription> get scriptWidgetSubscriptions;
|
||||
/// Create a copy of BackupBundle
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BackupBundleCopyWith<BackupBundle> get copyWith => _$BackupBundleCopyWithImpl<BackupBundle>(this as BackupBundle, _$identity);
|
||||
|
||||
/// Serializes this BackupBundle to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupBundle&&(identical(other.version, version) || other.version == version)&&(identical(other.exportedAt, exportedAt) || other.exportedAt == exportedAt)&&const DeepCollectionEquality().equals(other.servers, servers)&&const DeepCollectionEquality().equals(other.sessions, sessions)&&const DeepCollectionEquality().equals(other.danmakuSources, danmakuSources)&&const DeepCollectionEquality().equals(other.scriptWidgets, scriptWidgets)&&const DeepCollectionEquality().equals(other.scriptWidgetSubscriptions, scriptWidgetSubscriptions));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,version,exportedAt,const DeepCollectionEquality().hash(servers),const DeepCollectionEquality().hash(sessions),const DeepCollectionEquality().hash(danmakuSources),const DeepCollectionEquality().hash(scriptWidgets),const DeepCollectionEquality().hash(scriptWidgetSubscriptions));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackupBundle(version: $version, exportedAt: $exportedAt, servers: $servers, sessions: $sessions, danmakuSources: $danmakuSources, scriptWidgets: $scriptWidgets, scriptWidgetSubscriptions: $scriptWidgetSubscriptions)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BackupBundleCopyWith<$Res> {
|
||||
factory $BackupBundleCopyWith(BackupBundle value, $Res Function(BackupBundle) _then) = _$BackupBundleCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
int version, String exportedAt, List<EmbyServer> servers, List<SessionData> sessions,@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List<DanmakuSource> danmakuSources, List<InstalledScriptWidget> scriptWidgets, List<ScriptWidgetSubscription> scriptWidgetSubscriptions
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$BackupBundleCopyWithImpl<$Res>
|
||||
implements $BackupBundleCopyWith<$Res> {
|
||||
_$BackupBundleCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BackupBundle _self;
|
||||
final $Res Function(BackupBundle) _then;
|
||||
|
||||
/// Create a copy of BackupBundle
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? version = null,Object? exportedAt = null,Object? servers = null,Object? sessions = null,Object? danmakuSources = null,Object? scriptWidgets = null,Object? scriptWidgetSubscriptions = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
||||
as int,exportedAt: null == exportedAt ? _self.exportedAt : exportedAt // ignore: cast_nullable_to_non_nullable
|
||||
as String,servers: null == servers ? _self.servers : servers // ignore: cast_nullable_to_non_nullable
|
||||
as List<EmbyServer>,sessions: null == sessions ? _self.sessions : sessions // ignore: cast_nullable_to_non_nullable
|
||||
as List<SessionData>,danmakuSources: null == danmakuSources ? _self.danmakuSources : danmakuSources // ignore: cast_nullable_to_non_nullable
|
||||
as List<DanmakuSource>,scriptWidgets: null == scriptWidgets ? _self.scriptWidgets : scriptWidgets // ignore: cast_nullable_to_non_nullable
|
||||
as List<InstalledScriptWidget>,scriptWidgetSubscriptions: null == scriptWidgetSubscriptions ? _self.scriptWidgetSubscriptions : scriptWidgetSubscriptions // ignore: cast_nullable_to_non_nullable
|
||||
as List<ScriptWidgetSubscription>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [BackupBundle].
|
||||
extension BackupBundlePatterns on BackupBundle {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BackupBundle value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _BackupBundle() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BackupBundle value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _BackupBundle():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BackupBundle value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _BackupBundle() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int version, String exportedAt, List<EmbyServer> servers, List<SessionData> sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List<DanmakuSource> danmakuSources, List<InstalledScriptWidget> scriptWidgets, List<ScriptWidgetSubscription> scriptWidgetSubscriptions)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _BackupBundle() when $default != null:
|
||||
return $default(_that.version,_that.exportedAt,_that.servers,_that.sessions,_that.danmakuSources,_that.scriptWidgets,_that.scriptWidgetSubscriptions);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int version, String exportedAt, List<EmbyServer> servers, List<SessionData> sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List<DanmakuSource> danmakuSources, List<InstalledScriptWidget> scriptWidgets, List<ScriptWidgetSubscription> scriptWidgetSubscriptions) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _BackupBundle():
|
||||
return $default(_that.version,_that.exportedAt,_that.servers,_that.sessions,_that.danmakuSources,_that.scriptWidgets,_that.scriptWidgetSubscriptions);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int version, String exportedAt, List<EmbyServer> servers, List<SessionData> sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List<DanmakuSource> danmakuSources, List<InstalledScriptWidget> scriptWidgets, List<ScriptWidgetSubscription> scriptWidgetSubscriptions)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _BackupBundle() when $default != null:
|
||||
return $default(_that.version,_that.exportedAt,_that.servers,_that.sessions,_that.danmakuSources,_that.scriptWidgets,_that.scriptWidgetSubscriptions);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _BackupBundle implements BackupBundle {
|
||||
const _BackupBundle({required this.version, required this.exportedAt, required final List<EmbyServer> servers, required final List<SessionData> sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) final List<DanmakuSource> danmakuSources = const <DanmakuSource>[], final List<InstalledScriptWidget> scriptWidgets = const <InstalledScriptWidget>[], final List<ScriptWidgetSubscription> scriptWidgetSubscriptions = const <ScriptWidgetSubscription>[]}): _servers = servers,_sessions = sessions,_danmakuSources = danmakuSources,_scriptWidgets = scriptWidgets,_scriptWidgetSubscriptions = scriptWidgetSubscriptions;
|
||||
factory _BackupBundle.fromJson(Map<String, dynamic> json) => _$BackupBundleFromJson(json);
|
||||
|
||||
@override final int version;
|
||||
@override final String exportedAt;
|
||||
final List<EmbyServer> _servers;
|
||||
@override List<EmbyServer> get servers {
|
||||
if (_servers is EqualUnmodifiableListView) return _servers;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_servers);
|
||||
}
|
||||
|
||||
final List<SessionData> _sessions;
|
||||
@override List<SessionData> get sessions {
|
||||
if (_sessions is EqualUnmodifiableListView) return _sessions;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_sessions);
|
||||
}
|
||||
|
||||
final List<DanmakuSource> _danmakuSources;
|
||||
@override@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List<DanmakuSource> get danmakuSources {
|
||||
if (_danmakuSources is EqualUnmodifiableListView) return _danmakuSources;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_danmakuSources);
|
||||
}
|
||||
|
||||
final List<InstalledScriptWidget> _scriptWidgets;
|
||||
@override@JsonKey() List<InstalledScriptWidget> get scriptWidgets {
|
||||
if (_scriptWidgets is EqualUnmodifiableListView) return _scriptWidgets;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_scriptWidgets);
|
||||
}
|
||||
|
||||
final List<ScriptWidgetSubscription> _scriptWidgetSubscriptions;
|
||||
@override@JsonKey() List<ScriptWidgetSubscription> get scriptWidgetSubscriptions {
|
||||
if (_scriptWidgetSubscriptions is EqualUnmodifiableListView) return _scriptWidgetSubscriptions;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_scriptWidgetSubscriptions);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of BackupBundle
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$BackupBundleCopyWith<_BackupBundle> get copyWith => __$BackupBundleCopyWithImpl<_BackupBundle>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$BackupBundleToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupBundle&&(identical(other.version, version) || other.version == version)&&(identical(other.exportedAt, exportedAt) || other.exportedAt == exportedAt)&&const DeepCollectionEquality().equals(other._servers, _servers)&&const DeepCollectionEquality().equals(other._sessions, _sessions)&&const DeepCollectionEquality().equals(other._danmakuSources, _danmakuSources)&&const DeepCollectionEquality().equals(other._scriptWidgets, _scriptWidgets)&&const DeepCollectionEquality().equals(other._scriptWidgetSubscriptions, _scriptWidgetSubscriptions));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,version,exportedAt,const DeepCollectionEquality().hash(_servers),const DeepCollectionEquality().hash(_sessions),const DeepCollectionEquality().hash(_danmakuSources),const DeepCollectionEquality().hash(_scriptWidgets),const DeepCollectionEquality().hash(_scriptWidgetSubscriptions));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackupBundle(version: $version, exportedAt: $exportedAt, servers: $servers, sessions: $sessions, danmakuSources: $danmakuSources, scriptWidgets: $scriptWidgets, scriptWidgetSubscriptions: $scriptWidgetSubscriptions)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$BackupBundleCopyWith<$Res> implements $BackupBundleCopyWith<$Res> {
|
||||
factory _$BackupBundleCopyWith(_BackupBundle value, $Res Function(_BackupBundle) _then) = __$BackupBundleCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
int version, String exportedAt, List<EmbyServer> servers, List<SessionData> sessions,@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List<DanmakuSource> danmakuSources, List<InstalledScriptWidget> scriptWidgets, List<ScriptWidgetSubscription> scriptWidgetSubscriptions
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$BackupBundleCopyWithImpl<$Res>
|
||||
implements _$BackupBundleCopyWith<$Res> {
|
||||
__$BackupBundleCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _BackupBundle _self;
|
||||
final $Res Function(_BackupBundle) _then;
|
||||
|
||||
/// Create a copy of BackupBundle
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? version = null,Object? exportedAt = null,Object? servers = null,Object? sessions = null,Object? danmakuSources = null,Object? scriptWidgets = null,Object? scriptWidgetSubscriptions = null,}) {
|
||||
return _then(_BackupBundle(
|
||||
version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
||||
as int,exportedAt: null == exportedAt ? _self.exportedAt : exportedAt // ignore: cast_nullable_to_non_nullable
|
||||
as String,servers: null == servers ? _self._servers : servers // ignore: cast_nullable_to_non_nullable
|
||||
as List<EmbyServer>,sessions: null == sessions ? _self._sessions : sessions // ignore: cast_nullable_to_non_nullable
|
||||
as List<SessionData>,danmakuSources: null == danmakuSources ? _self._danmakuSources : danmakuSources // ignore: cast_nullable_to_non_nullable
|
||||
as List<DanmakuSource>,scriptWidgets: null == scriptWidgets ? _self._scriptWidgets : scriptWidgets // ignore: cast_nullable_to_non_nullable
|
||||
as List<InstalledScriptWidget>,scriptWidgetSubscriptions: null == scriptWidgetSubscriptions ? _self._scriptWidgetSubscriptions : scriptWidgetSubscriptions // ignore: cast_nullable_to_non_nullable
|
||||
as List<ScriptWidgetSubscription>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
Generated
+50
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'backup.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_BackupBundle _$BackupBundleFromJson(Map<String, dynamic> json) =>
|
||||
_BackupBundle(
|
||||
version: (json['version'] as num).toInt(),
|
||||
exportedAt: json['exportedAt'] as String,
|
||||
servers: (json['servers'] as List<dynamic>)
|
||||
.map((e) => EmbyServer.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
sessions: (json['sessions'] as List<dynamic>)
|
||||
.map((e) => SessionData.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
danmakuSources: json['danmakuSources'] == null
|
||||
? const <DanmakuSource>[]
|
||||
: _danmakuSourcesFromJson(json['danmakuSources']),
|
||||
scriptWidgets:
|
||||
(json['scriptWidgets'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) =>
|
||||
InstalledScriptWidget.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
const <InstalledScriptWidget>[],
|
||||
scriptWidgetSubscriptions:
|
||||
(json['scriptWidgetSubscriptions'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => ScriptWidgetSubscription.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
const <ScriptWidgetSubscription>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BackupBundleToJson(_BackupBundle instance) =>
|
||||
<String, dynamic>{
|
||||
'version': instance.version,
|
||||
'exportedAt': instance.exportedAt,
|
||||
'servers': instance.servers,
|
||||
'sessions': instance.sessions,
|
||||
'danmakuSources': _danmakuSourcesToJson(instance.danmakuSources),
|
||||
'scriptWidgets': instance.scriptWidgets,
|
||||
'scriptWidgetSubscriptions': instance.scriptWidgetSubscriptions,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:async';
|
||||
|
||||
class CancelledException implements Exception {
|
||||
final String? reason;
|
||||
const CancelledException([this.reason]);
|
||||
@override
|
||||
String toString() =>
|
||||
reason == null ? 'CancelledException' : 'CancelledException: $reason';
|
||||
}
|
||||
|
||||
class CancellationToken {
|
||||
final Completer<void> _completer = Completer<void>();
|
||||
String? _reason;
|
||||
|
||||
bool get isCancelled => _completer.isCompleted;
|
||||
String? get reason => _reason;
|
||||
Future<void> get whenCancelled => _completer.future;
|
||||
|
||||
void cancel([String? reason]) {
|
||||
if (_completer.isCompleted) return;
|
||||
_reason = reason;
|
||||
_completer.complete();
|
||||
}
|
||||
|
||||
void throwIfCancelled() {
|
||||
if (isCancelled) throw CancelledException(_reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export 'auth.dart';
|
||||
export 'backup.dart';
|
||||
export 'cancellation.dart';
|
||||
export 'error.dart';
|
||||
export 'library.dart';
|
||||
export 'playback.dart';
|
||||
export 'server.dart';
|
||||
export 'tmdb.dart';
|
||||
export 'danmaku.dart';
|
||||
export 'icon_library.dart';
|
||||
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'danmaku.freezed.dart';
|
||||
part 'danmaku.g.dart';
|
||||
|
||||
|
||||
class DanmakuPanelSettings {
|
||||
final bool enabled;
|
||||
final double opacity;
|
||||
final double area;
|
||||
final double fontSize;
|
||||
final double speed;
|
||||
final double offset;
|
||||
final bool showTop;
|
||||
final bool showScroll;
|
||||
final bool showBottom;
|
||||
final bool colored;
|
||||
final double strokeWidth;
|
||||
final bool bold;
|
||||
final String? fontFamily;
|
||||
final double lineHeight;
|
||||
final bool fixedToScroll;
|
||||
final int maxRows;
|
||||
final bool heatmapEnabled;
|
||||
|
||||
const DanmakuPanelSettings({
|
||||
this.enabled = true,
|
||||
this.opacity = 1.0,
|
||||
this.area = 0.85,
|
||||
this.fontSize = 20,
|
||||
this.speed = 1.0,
|
||||
this.offset = 0.0,
|
||||
this.showTop = true,
|
||||
this.showScroll = true,
|
||||
this.showBottom = false,
|
||||
this.colored = true,
|
||||
this.strokeWidth = 1.5,
|
||||
this.bold = false,
|
||||
this.fontFamily,
|
||||
this.lineHeight = 1.6,
|
||||
this.fixedToScroll = false,
|
||||
this.maxRows = 0,
|
||||
this.heatmapEnabled = true,
|
||||
});
|
||||
|
||||
DanmakuPanelSettings copyWith({
|
||||
bool? enabled,
|
||||
double? opacity,
|
||||
double? area,
|
||||
double? fontSize,
|
||||
double? speed,
|
||||
double? offset,
|
||||
bool? showTop,
|
||||
bool? showScroll,
|
||||
bool? showBottom,
|
||||
bool? colored,
|
||||
double? strokeWidth,
|
||||
bool? bold,
|
||||
Object? fontFamily = _sentinel,
|
||||
double? lineHeight,
|
||||
bool? fixedToScroll,
|
||||
int? maxRows,
|
||||
bool? heatmapEnabled,
|
||||
}) => DanmakuPanelSettings(
|
||||
enabled: enabled ?? this.enabled,
|
||||
opacity: opacity ?? this.opacity,
|
||||
area: area ?? this.area,
|
||||
fontSize: fontSize ?? this.fontSize,
|
||||
speed: speed ?? this.speed,
|
||||
offset: offset ?? this.offset,
|
||||
showTop: showTop ?? this.showTop,
|
||||
showScroll: showScroll ?? this.showScroll,
|
||||
showBottom: showBottom ?? this.showBottom,
|
||||
colored: colored ?? this.colored,
|
||||
strokeWidth: strokeWidth ?? this.strokeWidth,
|
||||
bold: bold ?? this.bold,
|
||||
fontFamily: fontFamily == _sentinel
|
||||
? this.fontFamily
|
||||
: fontFamily as String?,
|
||||
lineHeight: lineHeight ?? this.lineHeight,
|
||||
fixedToScroll: fixedToScroll ?? this.fixedToScroll,
|
||||
maxRows: maxRows ?? this.maxRows,
|
||||
heatmapEnabled: heatmapEnabled ?? this.heatmapEnabled,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is DanmakuPanelSettings &&
|
||||
enabled == other.enabled &&
|
||||
opacity == other.opacity &&
|
||||
area == other.area &&
|
||||
fontSize == other.fontSize &&
|
||||
speed == other.speed &&
|
||||
offset == other.offset &&
|
||||
showTop == other.showTop &&
|
||||
showScroll == other.showScroll &&
|
||||
showBottom == other.showBottom &&
|
||||
colored == other.colored &&
|
||||
strokeWidth == other.strokeWidth &&
|
||||
bold == other.bold &&
|
||||
fontFamily == other.fontFamily &&
|
||||
lineHeight == other.lineHeight &&
|
||||
fixedToScroll == other.fixedToScroll &&
|
||||
maxRows == other.maxRows &&
|
||||
heatmapEnabled == other.heatmapEnabled;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
enabled,
|
||||
opacity,
|
||||
area,
|
||||
fontSize,
|
||||
speed,
|
||||
offset,
|
||||
showTop,
|
||||
showScroll,
|
||||
showBottom,
|
||||
colored,
|
||||
strokeWidth,
|
||||
bold,
|
||||
fontFamily,
|
||||
lineHeight,
|
||||
fixedToScroll,
|
||||
maxRows,
|
||||
heatmapEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
const Object _sentinel = Object();
|
||||
|
||||
class DanmakuSource {
|
||||
final String id;
|
||||
final String name;
|
||||
final String url;
|
||||
final bool enabled;
|
||||
|
||||
const DanmakuSource({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.url,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
DanmakuSource copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? url,
|
||||
bool? enabled,
|
||||
}) => DanmakuSource(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
url: url ?? this.url,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
|
||||
String get displayName {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isNotEmpty) return trimmed;
|
||||
return url;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'url': url,
|
||||
'enabled': enabled,
|
||||
};
|
||||
|
||||
factory DanmakuSource.fromJson(Map<String, dynamic> json) {
|
||||
final url = (json['url'] ?? '').toString().trim();
|
||||
return DanmakuSource(
|
||||
id: (json['id'] ?? url).toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
url: url,
|
||||
enabled: json['enabled'] is bool ? json['enabled'] as bool : true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is DanmakuSource &&
|
||||
id == other.id &&
|
||||
name == other.name &&
|
||||
url == other.url &&
|
||||
enabled == other.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, name, url, enabled);
|
||||
}
|
||||
|
||||
|
||||
enum DanmakuCommentStatus { loading, ready, failed }
|
||||
|
||||
|
||||
sealed class DanmakuSessionState {
|
||||
const DanmakuSessionState();
|
||||
}
|
||||
|
||||
|
||||
class DanmakuSessionIdle extends DanmakuSessionState {
|
||||
const DanmakuSessionIdle();
|
||||
}
|
||||
|
||||
|
||||
class DanmakuSessionMatching extends DanmakuSessionState {
|
||||
const DanmakuSessionMatching();
|
||||
}
|
||||
|
||||
|
||||
class DanmakuSessionMatched extends DanmakuSessionState {
|
||||
final List<DanmakuMatchCandidate> matches;
|
||||
final int selectedIndex;
|
||||
final DanmakuCommentStatus commentStatus;
|
||||
final List<DanmakuComment> comments;
|
||||
|
||||
const DanmakuSessionMatched({
|
||||
required this.matches,
|
||||
required this.selectedIndex,
|
||||
required this.commentStatus,
|
||||
this.comments = const [],
|
||||
});
|
||||
|
||||
DanmakuSessionMatched copyWith({
|
||||
List<DanmakuMatchCandidate>? matches,
|
||||
int? selectedIndex,
|
||||
DanmakuCommentStatus? commentStatus,
|
||||
List<DanmakuComment>? comments,
|
||||
}) => DanmakuSessionMatched(
|
||||
matches: matches ?? this.matches,
|
||||
selectedIndex: selectedIndex ?? this.selectedIndex,
|
||||
commentStatus: commentStatus ?? this.commentStatus,
|
||||
comments: comments ?? this.comments,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class DanmakuSessionEmpty extends DanmakuSessionState {
|
||||
const DanmakuSessionEmpty();
|
||||
}
|
||||
|
||||
|
||||
class DanmakuSessionFailed extends DanmakuSessionState {
|
||||
final String message;
|
||||
const DanmakuSessionFailed(this.message);
|
||||
}
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class DanmakuMatchContext with _$DanmakuMatchContext {
|
||||
const factory DanmakuMatchContext({
|
||||
required String itemId,
|
||||
required String name,
|
||||
required String? type,
|
||||
required String? seriesName,
|
||||
required int? season,
|
||||
required int? episode,
|
||||
required int durationSec,
|
||||
}) = _DanmakuMatchContext;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class DanmakuMatch with _$DanmakuMatch {
|
||||
const factory DanmakuMatch({
|
||||
@JsonKey(fromJson: _intRequired) required int episodeId,
|
||||
@JsonKey(fromJson: _intOrZero) @Default(0) int animeId,
|
||||
@Default('') String animeTitle,
|
||||
@Default('') String episodeTitle,
|
||||
}) = _DanmakuMatch;
|
||||
|
||||
factory DanmakuMatch.fromJson(Map<String, dynamic> json) =>
|
||||
_$DanmakuMatchFromJson(json);
|
||||
}
|
||||
|
||||
class DanmakuMatchCandidate {
|
||||
final DanmakuSource source;
|
||||
final DanmakuMatch match;
|
||||
|
||||
const DanmakuMatchCandidate({required this.source, required this.match});
|
||||
|
||||
int get episodeId => match.episodeId;
|
||||
int get animeId => match.animeId;
|
||||
String get animeTitle => match.animeTitle;
|
||||
String get episodeTitle => match.episodeTitle;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is DanmakuMatchCandidate &&
|
||||
source == other.source &&
|
||||
match == other.match;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(source, match);
|
||||
}
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class DanmakuBangumiEpisode with _$DanmakuBangumiEpisode {
|
||||
const factory DanmakuBangumiEpisode({
|
||||
@JsonKey(fromJson: _intRequired) required int episodeId,
|
||||
@Default('') String episodeTitle,
|
||||
@JsonKey(fromJson: _stringFromAny) @Default('') String episodeNumber,
|
||||
}) = _DanmakuBangumiEpisode;
|
||||
|
||||
factory DanmakuBangumiEpisode.fromJson(Map<String, dynamic> json) =>
|
||||
_$DanmakuBangumiEpisodeFromJson(json);
|
||||
}
|
||||
|
||||
int _intRequired(Object? value) => (value as num).toInt();
|
||||
|
||||
int _intOrZero(Object? value) => (value as num?)?.toInt() ?? 0;
|
||||
|
||||
String _stringFromAny(Object? value) => value?.toString() ?? '';
|
||||
|
||||
class DanmakuComment {
|
||||
final int cid;
|
||||
final double time;
|
||||
final int type;
|
||||
final Color color;
|
||||
final String text;
|
||||
int lane;
|
||||
|
||||
DanmakuComment({
|
||||
required this.cid,
|
||||
required this.time,
|
||||
required this.type,
|
||||
required this.color,
|
||||
required this.text,
|
||||
this.lane = 0,
|
||||
});
|
||||
|
||||
|
||||
factory DanmakuComment.fromRaw(int cid, String p, String m) {
|
||||
final parts = p.split(',');
|
||||
final time = parts.isNotEmpty ? (double.tryParse(parts[0]) ?? 0.0) : 0.0;
|
||||
final type = parts.length > 1 ? (int.tryParse(parts[1]) ?? 1) : 1;
|
||||
final colorInt = parts.length > 2
|
||||
? (int.tryParse(parts[2]) ?? 0xFFFFFF)
|
||||
: 0xFFFFFF;
|
||||
return DanmakuComment(
|
||||
cid: cid,
|
||||
time: time,
|
||||
type: type,
|
||||
color: Color(0xFF000000 | (colorInt & 0xFFFFFF)),
|
||||
text: m,
|
||||
);
|
||||
}
|
||||
}
|
||||
Generated
+830
@@ -0,0 +1,830 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'danmaku.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$DanmakuMatchContext {
|
||||
|
||||
String get itemId; String get name; String? get type; String? get seriesName; int? get season; int? get episode; int get durationSec;
|
||||
/// Create a copy of DanmakuMatchContext
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$DanmakuMatchContextCopyWith<DanmakuMatchContext> get copyWith => _$DanmakuMatchContextCopyWithImpl<DanmakuMatchContext>(this as DanmakuMatchContext, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is DanmakuMatchContext&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&(identical(other.seriesName, seriesName) || other.seriesName == seriesName)&&(identical(other.season, season) || other.season == season)&&(identical(other.episode, episode) || other.episode == episode)&&(identical(other.durationSec, durationSec) || other.durationSec == durationSec));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,itemId,name,type,seriesName,season,episode,durationSec);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DanmakuMatchContext(itemId: $itemId, name: $name, type: $type, seriesName: $seriesName, season: $season, episode: $episode, durationSec: $durationSec)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $DanmakuMatchContextCopyWith<$Res> {
|
||||
factory $DanmakuMatchContextCopyWith(DanmakuMatchContext value, $Res Function(DanmakuMatchContext) _then) = _$DanmakuMatchContextCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$DanmakuMatchContextCopyWithImpl<$Res>
|
||||
implements $DanmakuMatchContextCopyWith<$Res> {
|
||||
_$DanmakuMatchContextCopyWithImpl(this._self, this._then);
|
||||
|
||||
final DanmakuMatchContext _self;
|
||||
final $Res Function(DanmakuMatchContext) _then;
|
||||
|
||||
/// Create a copy of DanmakuMatchContext
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? name = null,Object? type = freezed,Object? seriesName = freezed,Object? season = freezed,Object? episode = freezed,Object? durationSec = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: freezed == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String?,seriesName: freezed == seriesName ? _self.seriesName : seriesName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,season: freezed == season ? _self.season : season // ignore: cast_nullable_to_non_nullable
|
||||
as int?,episode: freezed == episode ? _self.episode : episode // ignore: cast_nullable_to_non_nullable
|
||||
as int?,durationSec: null == durationSec ? _self.durationSec : durationSec // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [DanmakuMatchContext].
|
||||
extension DanmakuMatchContextPatterns on DanmakuMatchContext {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _DanmakuMatchContext value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatchContext() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _DanmakuMatchContext value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatchContext():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _DanmakuMatchContext value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatchContext() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatchContext() when $default != null:
|
||||
return $default(_that.itemId,_that.name,_that.type,_that.seriesName,_that.season,_that.episode,_that.durationSec);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatchContext():
|
||||
return $default(_that.itemId,_that.name,_that.type,_that.seriesName,_that.season,_that.episode,_that.durationSec);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatchContext() when $default != null:
|
||||
return $default(_that.itemId,_that.name,_that.type,_that.seriesName,_that.season,_that.episode,_that.durationSec);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _DanmakuMatchContext implements DanmakuMatchContext {
|
||||
const _DanmakuMatchContext({required this.itemId, required this.name, required this.type, required this.seriesName, required this.season, required this.episode, required this.durationSec});
|
||||
|
||||
|
||||
@override final String itemId;
|
||||
@override final String name;
|
||||
@override final String? type;
|
||||
@override final String? seriesName;
|
||||
@override final int? season;
|
||||
@override final int? episode;
|
||||
@override final int durationSec;
|
||||
|
||||
/// Create a copy of DanmakuMatchContext
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$DanmakuMatchContextCopyWith<_DanmakuMatchContext> get copyWith => __$DanmakuMatchContextCopyWithImpl<_DanmakuMatchContext>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DanmakuMatchContext&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&(identical(other.seriesName, seriesName) || other.seriesName == seriesName)&&(identical(other.season, season) || other.season == season)&&(identical(other.episode, episode) || other.episode == episode)&&(identical(other.durationSec, durationSec) || other.durationSec == durationSec));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,itemId,name,type,seriesName,season,episode,durationSec);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DanmakuMatchContext(itemId: $itemId, name: $name, type: $type, seriesName: $seriesName, season: $season, episode: $episode, durationSec: $durationSec)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$DanmakuMatchContextCopyWith<$Res> implements $DanmakuMatchContextCopyWith<$Res> {
|
||||
factory _$DanmakuMatchContextCopyWith(_DanmakuMatchContext value, $Res Function(_DanmakuMatchContext) _then) = __$DanmakuMatchContextCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$DanmakuMatchContextCopyWithImpl<$Res>
|
||||
implements _$DanmakuMatchContextCopyWith<$Res> {
|
||||
__$DanmakuMatchContextCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _DanmakuMatchContext _self;
|
||||
final $Res Function(_DanmakuMatchContext) _then;
|
||||
|
||||
/// Create a copy of DanmakuMatchContext
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? name = null,Object? type = freezed,Object? seriesName = freezed,Object? season = freezed,Object? episode = freezed,Object? durationSec = null,}) {
|
||||
return _then(_DanmakuMatchContext(
|
||||
itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: freezed == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String?,seriesName: freezed == seriesName ? _self.seriesName : seriesName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,season: freezed == season ? _self.season : season // ignore: cast_nullable_to_non_nullable
|
||||
as int?,episode: freezed == episode ? _self.episode : episode // ignore: cast_nullable_to_non_nullable
|
||||
as int?,durationSec: null == durationSec ? _self.durationSec : durationSec // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$DanmakuMatch {
|
||||
|
||||
@JsonKey(fromJson: _intRequired) int get episodeId;@JsonKey(fromJson: _intOrZero) int get animeId; String get animeTitle; String get episodeTitle;
|
||||
/// Create a copy of DanmakuMatch
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$DanmakuMatchCopyWith<DanmakuMatch> get copyWith => _$DanmakuMatchCopyWithImpl<DanmakuMatch>(this as DanmakuMatch, _$identity);
|
||||
|
||||
/// Serializes this DanmakuMatch to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is DanmakuMatch&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.animeId, animeId) || other.animeId == animeId)&&(identical(other.animeTitle, animeTitle) || other.animeTitle == animeTitle)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,episodeId,animeId,animeTitle,episodeTitle);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DanmakuMatch(episodeId: $episodeId, animeId: $animeId, animeTitle: $animeTitle, episodeTitle: $episodeTitle)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $DanmakuMatchCopyWith<$Res> {
|
||||
factory $DanmakuMatchCopyWith(DanmakuMatch value, $Res Function(DanmakuMatch) _then) = _$DanmakuMatchCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int episodeId,@JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$DanmakuMatchCopyWithImpl<$Res>
|
||||
implements $DanmakuMatchCopyWith<$Res> {
|
||||
_$DanmakuMatchCopyWithImpl(this._self, this._then);
|
||||
|
||||
final DanmakuMatch _self;
|
||||
final $Res Function(DanmakuMatch) _then;
|
||||
|
||||
/// Create a copy of DanmakuMatch
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? episodeId = null,Object? animeId = null,Object? animeTitle = null,Object? episodeTitle = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable
|
||||
as int,animeId: null == animeId ? _self.animeId : animeId // ignore: cast_nullable_to_non_nullable
|
||||
as int,animeTitle: null == animeTitle ? _self.animeTitle : animeTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [DanmakuMatch].
|
||||
extension DanmakuMatchPatterns on DanmakuMatch {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _DanmakuMatch value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatch() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _DanmakuMatch value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatch():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _DanmakuMatch value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatch() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, @JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatch() when $default != null:
|
||||
return $default(_that.episodeId,_that.animeId,_that.animeTitle,_that.episodeTitle);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, @JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatch():
|
||||
return $default(_that.episodeId,_that.animeId,_that.animeTitle,_that.episodeTitle);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(fromJson: _intRequired) int episodeId, @JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuMatch() when $default != null:
|
||||
return $default(_that.episodeId,_that.animeId,_that.animeTitle,_that.episodeTitle);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _DanmakuMatch implements DanmakuMatch {
|
||||
const _DanmakuMatch({@JsonKey(fromJson: _intRequired) required this.episodeId, @JsonKey(fromJson: _intOrZero) this.animeId = 0, this.animeTitle = '', this.episodeTitle = ''});
|
||||
factory _DanmakuMatch.fromJson(Map<String, dynamic> json) => _$DanmakuMatchFromJson(json);
|
||||
|
||||
@override@JsonKey(fromJson: _intRequired) final int episodeId;
|
||||
@override@JsonKey(fromJson: _intOrZero) final int animeId;
|
||||
@override@JsonKey() final String animeTitle;
|
||||
@override@JsonKey() final String episodeTitle;
|
||||
|
||||
/// Create a copy of DanmakuMatch
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$DanmakuMatchCopyWith<_DanmakuMatch> get copyWith => __$DanmakuMatchCopyWithImpl<_DanmakuMatch>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$DanmakuMatchToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DanmakuMatch&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.animeId, animeId) || other.animeId == animeId)&&(identical(other.animeTitle, animeTitle) || other.animeTitle == animeTitle)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,episodeId,animeId,animeTitle,episodeTitle);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DanmakuMatch(episodeId: $episodeId, animeId: $animeId, animeTitle: $animeTitle, episodeTitle: $episodeTitle)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$DanmakuMatchCopyWith<$Res> implements $DanmakuMatchCopyWith<$Res> {
|
||||
factory _$DanmakuMatchCopyWith(_DanmakuMatch value, $Res Function(_DanmakuMatch) _then) = __$DanmakuMatchCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int episodeId,@JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$DanmakuMatchCopyWithImpl<$Res>
|
||||
implements _$DanmakuMatchCopyWith<$Res> {
|
||||
__$DanmakuMatchCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _DanmakuMatch _self;
|
||||
final $Res Function(_DanmakuMatch) _then;
|
||||
|
||||
/// Create a copy of DanmakuMatch
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? episodeId = null,Object? animeId = null,Object? animeTitle = null,Object? episodeTitle = null,}) {
|
||||
return _then(_DanmakuMatch(
|
||||
episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable
|
||||
as int,animeId: null == animeId ? _self.animeId : animeId // ignore: cast_nullable_to_non_nullable
|
||||
as int,animeTitle: null == animeTitle ? _self.animeTitle : animeTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$DanmakuBangumiEpisode {
|
||||
|
||||
@JsonKey(fromJson: _intRequired) int get episodeId; String get episodeTitle;@JsonKey(fromJson: _stringFromAny) String get episodeNumber;
|
||||
/// Create a copy of DanmakuBangumiEpisode
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$DanmakuBangumiEpisodeCopyWith<DanmakuBangumiEpisode> get copyWith => _$DanmakuBangumiEpisodeCopyWithImpl<DanmakuBangumiEpisode>(this as DanmakuBangumiEpisode, _$identity);
|
||||
|
||||
/// Serializes this DanmakuBangumiEpisode to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is DanmakuBangumiEpisode&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle)&&(identical(other.episodeNumber, episodeNumber) || other.episodeNumber == episodeNumber));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,episodeId,episodeTitle,episodeNumber);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DanmakuBangumiEpisode(episodeId: $episodeId, episodeTitle: $episodeTitle, episodeNumber: $episodeNumber)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $DanmakuBangumiEpisodeCopyWith<$Res> {
|
||||
factory $DanmakuBangumiEpisodeCopyWith(DanmakuBangumiEpisode value, $Res Function(DanmakuBangumiEpisode) _then) = _$DanmakuBangumiEpisodeCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle,@JsonKey(fromJson: _stringFromAny) String episodeNumber
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$DanmakuBangumiEpisodeCopyWithImpl<$Res>
|
||||
implements $DanmakuBangumiEpisodeCopyWith<$Res> {
|
||||
_$DanmakuBangumiEpisodeCopyWithImpl(this._self, this._then);
|
||||
|
||||
final DanmakuBangumiEpisode _self;
|
||||
final $Res Function(DanmakuBangumiEpisode) _then;
|
||||
|
||||
/// Create a copy of DanmakuBangumiEpisode
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? episodeId = null,Object? episodeTitle = null,Object? episodeNumber = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable
|
||||
as int,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String,episodeNumber: null == episodeNumber ? _self.episodeNumber : episodeNumber // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [DanmakuBangumiEpisode].
|
||||
extension DanmakuBangumiEpisodePatterns on DanmakuBangumiEpisode {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _DanmakuBangumiEpisode value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuBangumiEpisode() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _DanmakuBangumiEpisode value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuBangumiEpisode():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _DanmakuBangumiEpisode value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuBangumiEpisode() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle, @JsonKey(fromJson: _stringFromAny) String episodeNumber)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuBangumiEpisode() when $default != null:
|
||||
return $default(_that.episodeId,_that.episodeTitle,_that.episodeNumber);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle, @JsonKey(fromJson: _stringFromAny) String episodeNumber) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuBangumiEpisode():
|
||||
return $default(_that.episodeId,_that.episodeTitle,_that.episodeNumber);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle, @JsonKey(fromJson: _stringFromAny) String episodeNumber)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _DanmakuBangumiEpisode() when $default != null:
|
||||
return $default(_that.episodeId,_that.episodeTitle,_that.episodeNumber);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _DanmakuBangumiEpisode implements DanmakuBangumiEpisode {
|
||||
const _DanmakuBangumiEpisode({@JsonKey(fromJson: _intRequired) required this.episodeId, this.episodeTitle = '', @JsonKey(fromJson: _stringFromAny) this.episodeNumber = ''});
|
||||
factory _DanmakuBangumiEpisode.fromJson(Map<String, dynamic> json) => _$DanmakuBangumiEpisodeFromJson(json);
|
||||
|
||||
@override@JsonKey(fromJson: _intRequired) final int episodeId;
|
||||
@override@JsonKey() final String episodeTitle;
|
||||
@override@JsonKey(fromJson: _stringFromAny) final String episodeNumber;
|
||||
|
||||
/// Create a copy of DanmakuBangumiEpisode
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$DanmakuBangumiEpisodeCopyWith<_DanmakuBangumiEpisode> get copyWith => __$DanmakuBangumiEpisodeCopyWithImpl<_DanmakuBangumiEpisode>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$DanmakuBangumiEpisodeToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DanmakuBangumiEpisode&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle)&&(identical(other.episodeNumber, episodeNumber) || other.episodeNumber == episodeNumber));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,episodeId,episodeTitle,episodeNumber);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DanmakuBangumiEpisode(episodeId: $episodeId, episodeTitle: $episodeTitle, episodeNumber: $episodeNumber)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$DanmakuBangumiEpisodeCopyWith<$Res> implements $DanmakuBangumiEpisodeCopyWith<$Res> {
|
||||
factory _$DanmakuBangumiEpisodeCopyWith(_DanmakuBangumiEpisode value, $Res Function(_DanmakuBangumiEpisode) _then) = __$DanmakuBangumiEpisodeCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle,@JsonKey(fromJson: _stringFromAny) String episodeNumber
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$DanmakuBangumiEpisodeCopyWithImpl<$Res>
|
||||
implements _$DanmakuBangumiEpisodeCopyWith<$Res> {
|
||||
__$DanmakuBangumiEpisodeCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _DanmakuBangumiEpisode _self;
|
||||
final $Res Function(_DanmakuBangumiEpisode) _then;
|
||||
|
||||
/// Create a copy of DanmakuBangumiEpisode
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? episodeId = null,Object? episodeTitle = null,Object? episodeNumber = null,}) {
|
||||
return _then(_DanmakuBangumiEpisode(
|
||||
episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable
|
||||
as int,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String,episodeNumber: null == episodeNumber ? _self.episodeNumber : episodeNumber // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
Generated
+41
@@ -0,0 +1,41 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'danmaku.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_DanmakuMatch _$DanmakuMatchFromJson(Map<String, dynamic> json) =>
|
||||
_DanmakuMatch(
|
||||
episodeId: _intRequired(json['episodeId']),
|
||||
animeId: json['animeId'] == null ? 0 : _intOrZero(json['animeId']),
|
||||
animeTitle: json['animeTitle'] as String? ?? '',
|
||||
episodeTitle: json['episodeTitle'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DanmakuMatchToJson(_DanmakuMatch instance) =>
|
||||
<String, dynamic>{
|
||||
'episodeId': instance.episodeId,
|
||||
'animeId': instance.animeId,
|
||||
'animeTitle': instance.animeTitle,
|
||||
'episodeTitle': instance.episodeTitle,
|
||||
};
|
||||
|
||||
_DanmakuBangumiEpisode _$DanmakuBangumiEpisodeFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _DanmakuBangumiEpisode(
|
||||
episodeId: _intRequired(json['episodeId']),
|
||||
episodeTitle: json['episodeTitle'] as String? ?? '',
|
||||
episodeNumber: json['episodeNumber'] == null
|
||||
? ''
|
||||
: _stringFromAny(json['episodeNumber']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DanmakuBangumiEpisodeToJson(
|
||||
_DanmakuBangumiEpisode instance,
|
||||
) => <String, dynamic>{
|
||||
'episodeId': instance.episodeId,
|
||||
'episodeTitle': instance.episodeTitle,
|
||||
'episodeNumber': instance.episodeNumber,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
enum ErrorCode {
|
||||
invalidParams('INVALID_PARAMS'),
|
||||
serverUnreachable('SERVER_UNREACHABLE'),
|
||||
serverTimeout('SERVER_TIMEOUT'),
|
||||
serverHttpError('SERVER_HTTP_ERROR'),
|
||||
authInvalidCredentials('AUTH_INVALID_CREDENTIALS'),
|
||||
authForbidden('AUTH_FORBIDDEN'),
|
||||
storeConflict('STORE_CONFLICT'),
|
||||
storeNotFound('STORE_NOT_FOUND'),
|
||||
internalError('INTERNAL_ERROR');
|
||||
|
||||
final String value;
|
||||
const ErrorCode(this.value);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'icon_library.freezed.dart';
|
||||
part 'icon_library.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class IconEntry with _$IconEntry {
|
||||
const factory IconEntry({
|
||||
@JsonKey(fromJson: _stringOrEmpty) @Default('') String name,
|
||||
@JsonKey(fromJson: _stringOrEmpty) @Default('') String url,
|
||||
}) = _IconEntry;
|
||||
|
||||
factory IconEntry.fromJson(Map<String, dynamic> json) =>
|
||||
_$IconEntryFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class IconLibrary with _$IconLibrary {
|
||||
const factory IconLibrary({
|
||||
|
||||
required String sourceUrl,
|
||||
@JsonKey(fromJson: _stringOrEmpty) @Default('') String name,
|
||||
@JsonKey(fromJson: _stringOrEmpty) @Default('') String description,
|
||||
@Default(<IconEntry>[]) List<IconEntry> icons,
|
||||
}) = _IconLibrary;
|
||||
|
||||
factory IconLibrary.fromJson(Map<String, dynamic> json) =>
|
||||
_$IconLibraryFromJson(json);
|
||||
|
||||
|
||||
static IconLibrary parse(String sourceUrl, Map<String, dynamic> json) {
|
||||
final raw = json['icons'];
|
||||
final iconsList = raw is List
|
||||
? raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(IconEntry.fromJson)
|
||||
.where((e) => e.url.isNotEmpty)
|
||||
.toList()
|
||||
: const <IconEntry>[];
|
||||
return IconLibrary(
|
||||
sourceUrl: sourceUrl,
|
||||
name: (json['name'] as String?) ?? '',
|
||||
description: (json['description'] as String?) ?? '',
|
||||
icons: iconsList,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _stringOrEmpty(Object? value) => value?.toString() ?? '';
|
||||
+560
@@ -0,0 +1,560 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'icon_library.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$IconEntry {
|
||||
|
||||
@JsonKey(fromJson: _stringOrEmpty) String get name;@JsonKey(fromJson: _stringOrEmpty) String get url;
|
||||
/// Create a copy of IconEntry
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$IconEntryCopyWith<IconEntry> get copyWith => _$IconEntryCopyWithImpl<IconEntry>(this as IconEntry, _$identity);
|
||||
|
||||
/// Serializes this IconEntry to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is IconEntry&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,name,url);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'IconEntry(name: $name, url: $url)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $IconEntryCopyWith<$Res> {
|
||||
factory $IconEntryCopyWith(IconEntry value, $Res Function(IconEntry) _then) = _$IconEntryCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String url
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$IconEntryCopyWithImpl<$Res>
|
||||
implements $IconEntryCopyWith<$Res> {
|
||||
_$IconEntryCopyWithImpl(this._self, this._then);
|
||||
|
||||
final IconEntry _self;
|
||||
final $Res Function(IconEntry) _then;
|
||||
|
||||
/// Create a copy of IconEntry
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? url = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [IconEntry].
|
||||
extension IconEntryPatterns on IconEntry {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _IconEntry value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _IconEntry() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _IconEntry value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _IconEntry():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _IconEntry value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _IconEntry() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String url)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _IconEntry() when $default != null:
|
||||
return $default(_that.name,_that.url);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String url) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _IconEntry():
|
||||
return $default(_that.name,_that.url);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String url)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _IconEntry() when $default != null:
|
||||
return $default(_that.name,_that.url);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _IconEntry implements IconEntry {
|
||||
const _IconEntry({@JsonKey(fromJson: _stringOrEmpty) this.name = '', @JsonKey(fromJson: _stringOrEmpty) this.url = ''});
|
||||
factory _IconEntry.fromJson(Map<String, dynamic> json) => _$IconEntryFromJson(json);
|
||||
|
||||
@override@JsonKey(fromJson: _stringOrEmpty) final String name;
|
||||
@override@JsonKey(fromJson: _stringOrEmpty) final String url;
|
||||
|
||||
/// Create a copy of IconEntry
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$IconEntryCopyWith<_IconEntry> get copyWith => __$IconEntryCopyWithImpl<_IconEntry>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$IconEntryToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _IconEntry&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,name,url);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'IconEntry(name: $name, url: $url)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$IconEntryCopyWith<$Res> implements $IconEntryCopyWith<$Res> {
|
||||
factory _$IconEntryCopyWith(_IconEntry value, $Res Function(_IconEntry) _then) = __$IconEntryCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String url
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$IconEntryCopyWithImpl<$Res>
|
||||
implements _$IconEntryCopyWith<$Res> {
|
||||
__$IconEntryCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _IconEntry _self;
|
||||
final $Res Function(_IconEntry) _then;
|
||||
|
||||
/// Create a copy of IconEntry
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? url = null,}) {
|
||||
return _then(_IconEntry(
|
||||
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$IconLibrary {
|
||||
|
||||
/// 来源 URL(去除尾随斜杠后的标准化字符串)
|
||||
String get sourceUrl;@JsonKey(fromJson: _stringOrEmpty) String get name;@JsonKey(fromJson: _stringOrEmpty) String get description; List<IconEntry> get icons;
|
||||
/// Create a copy of IconLibrary
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$IconLibraryCopyWith<IconLibrary> get copyWith => _$IconLibraryCopyWithImpl<IconLibrary>(this as IconLibrary, _$identity);
|
||||
|
||||
/// Serializes this IconLibrary to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is IconLibrary&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.icons, icons));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,sourceUrl,name,description,const DeepCollectionEquality().hash(icons));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'IconLibrary(sourceUrl: $sourceUrl, name: $name, description: $description, icons: $icons)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $IconLibraryCopyWith<$Res> {
|
||||
factory $IconLibraryCopyWith(IconLibrary value, $Res Function(IconLibrary) _then) = _$IconLibraryCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String sourceUrl,@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String description, List<IconEntry> icons
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$IconLibraryCopyWithImpl<$Res>
|
||||
implements $IconLibraryCopyWith<$Res> {
|
||||
_$IconLibraryCopyWithImpl(this._self, this._then);
|
||||
|
||||
final IconLibrary _self;
|
||||
final $Res Function(IconLibrary) _then;
|
||||
|
||||
/// Create a copy of IconLibrary
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? sourceUrl = null,Object? name = null,Object? description = null,Object? icons = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
sourceUrl: null == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,icons: null == icons ? _self.icons : icons // ignore: cast_nullable_to_non_nullable
|
||||
as List<IconEntry>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [IconLibrary].
|
||||
extension IconLibraryPatterns on IconLibrary {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _IconLibrary value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _IconLibrary() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _IconLibrary value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _IconLibrary():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _IconLibrary value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _IconLibrary() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String sourceUrl, @JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String description, List<IconEntry> icons)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _IconLibrary() when $default != null:
|
||||
return $default(_that.sourceUrl,_that.name,_that.description,_that.icons);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String sourceUrl, @JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String description, List<IconEntry> icons) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _IconLibrary():
|
||||
return $default(_that.sourceUrl,_that.name,_that.description,_that.icons);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String sourceUrl, @JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String description, List<IconEntry> icons)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _IconLibrary() when $default != null:
|
||||
return $default(_that.sourceUrl,_that.name,_that.description,_that.icons);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _IconLibrary implements IconLibrary {
|
||||
const _IconLibrary({required this.sourceUrl, @JsonKey(fromJson: _stringOrEmpty) this.name = '', @JsonKey(fromJson: _stringOrEmpty) this.description = '', final List<IconEntry> icons = const <IconEntry>[]}): _icons = icons;
|
||||
factory _IconLibrary.fromJson(Map<String, dynamic> json) => _$IconLibraryFromJson(json);
|
||||
|
||||
/// 来源 URL(去除尾随斜杠后的标准化字符串)
|
||||
@override final String sourceUrl;
|
||||
@override@JsonKey(fromJson: _stringOrEmpty) final String name;
|
||||
@override@JsonKey(fromJson: _stringOrEmpty) final String description;
|
||||
final List<IconEntry> _icons;
|
||||
@override@JsonKey() List<IconEntry> get icons {
|
||||
if (_icons is EqualUnmodifiableListView) return _icons;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_icons);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of IconLibrary
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$IconLibraryCopyWith<_IconLibrary> get copyWith => __$IconLibraryCopyWithImpl<_IconLibrary>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$IconLibraryToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _IconLibrary&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._icons, _icons));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,sourceUrl,name,description,const DeepCollectionEquality().hash(_icons));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'IconLibrary(sourceUrl: $sourceUrl, name: $name, description: $description, icons: $icons)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$IconLibraryCopyWith<$Res> implements $IconLibraryCopyWith<$Res> {
|
||||
factory _$IconLibraryCopyWith(_IconLibrary value, $Res Function(_IconLibrary) _then) = __$IconLibraryCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String sourceUrl,@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String description, List<IconEntry> icons
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$IconLibraryCopyWithImpl<$Res>
|
||||
implements _$IconLibraryCopyWith<$Res> {
|
||||
__$IconLibraryCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _IconLibrary _self;
|
||||
final $Res Function(_IconLibrary) _then;
|
||||
|
||||
/// Create a copy of IconLibrary
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? sourceUrl = null,Object? name = null,Object? description = null,Object? icons = null,}) {
|
||||
return _then(_IconLibrary(
|
||||
sourceUrl: null == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,icons: null == icons ? _self._icons : icons // ignore: cast_nullable_to_non_nullable
|
||||
as List<IconEntry>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
Generated
+36
@@ -0,0 +1,36 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'icon_library.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_IconEntry _$IconEntryFromJson(Map<String, dynamic> json) => _IconEntry(
|
||||
name: json['name'] == null ? '' : _stringOrEmpty(json['name']),
|
||||
url: json['url'] == null ? '' : _stringOrEmpty(json['url']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$IconEntryToJson(_IconEntry instance) =>
|
||||
<String, dynamic>{'name': instance.name, 'url': instance.url};
|
||||
|
||||
_IconLibrary _$IconLibraryFromJson(Map<String, dynamic> json) => _IconLibrary(
|
||||
sourceUrl: json['sourceUrl'] as String,
|
||||
name: json['name'] == null ? '' : _stringOrEmpty(json['name']),
|
||||
description: json['description'] == null
|
||||
? ''
|
||||
: _stringOrEmpty(json['description']),
|
||||
icons:
|
||||
(json['icons'] as List<dynamic>?)
|
||||
?.map((e) => IconEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <IconEntry>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$IconLibraryToJson(_IconLibrary instance) =>
|
||||
<String, dynamic>{
|
||||
'sourceUrl': instance.sourceUrl,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'icons': instance.icons,
|
||||
};
|
||||
@@ -0,0 +1,722 @@
|
||||
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'library.freezed.dart';
|
||||
part 'library.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawUserData with _$EmbyRawUserData {
|
||||
const factory EmbyRawUserData({
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false)
|
||||
int? PlaybackPositionTicks,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount,
|
||||
@JsonKey(includeIfNull: false) bool? IsFavorite,
|
||||
@JsonKey(includeIfNull: false) bool? Played,
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@Default(<String, dynamic>{})
|
||||
Map<String, dynamic> extra,
|
||||
}) = _EmbyRawUserData;
|
||||
|
||||
factory EmbyRawUserData.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyRawUserDataFromJson(json).copyWith(extra: json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawItem with _$EmbyRawItem {
|
||||
const factory EmbyRawItem({
|
||||
required String Id,
|
||||
required String Name,
|
||||
@JsonKey(includeIfNull: false) String? Type,
|
||||
@JsonKey(includeIfNull: false) String? Overview,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear,
|
||||
@JsonKey(fromJson: _doubleOrNull, includeIfNull: false)
|
||||
double? CommunityRating,
|
||||
@JsonKey(includeIfNull: false) String? PremiereDate,
|
||||
@JsonKey(includeIfNull: false) String? SeriesName,
|
||||
@JsonKey(includeIfNull: false) String? SeriesId,
|
||||
@JsonKey(includeIfNull: false) String? SeasonId,
|
||||
@JsonKey(includeIfNull: false) String? SeasonName,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber,
|
||||
@JsonKey(includeIfNull: false) String? ParentId,
|
||||
@JsonKey(includeIfNull: false) String? PrimaryImageTag,
|
||||
@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false)
|
||||
Map<String, String>? ImageTags,
|
||||
@JsonKey(fromJson: _stringListOrNull, includeIfNull: false)
|
||||
List<String>? BackdropImageTags,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks,
|
||||
@JsonKey(includeIfNull: false) EmbyRawUserData? UserData,
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@Default(<String, dynamic>{})
|
||||
Map<String, dynamic> extra,
|
||||
}) = _EmbyRawItem;
|
||||
|
||||
factory EmbyRawItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyRawItemFromJson(json).copyWith(extra: json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawSeason with _$EmbyRawSeason {
|
||||
const factory EmbyRawSeason({
|
||||
required String Id,
|
||||
required String Name,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount,
|
||||
@JsonKey(includeIfNull: false) String? PrimaryImageTag,
|
||||
@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false)
|
||||
Map<String, String>? ImageTags,
|
||||
}) = _EmbyRawSeason;
|
||||
|
||||
factory EmbyRawSeason.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyRawSeasonFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawLibrary with _$EmbyRawLibrary {
|
||||
const factory EmbyRawLibrary({
|
||||
required String Id,
|
||||
required String Name,
|
||||
@JsonKey(includeIfNull: false) String? CollectionType,
|
||||
@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false)
|
||||
Map<String, String>? ImageTags,
|
||||
}) = _EmbyRawLibrary;
|
||||
|
||||
factory EmbyRawLibrary.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyRawLibraryFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawLibraries with _$EmbyRawLibraries {
|
||||
const factory EmbyRawLibraries({required List<EmbyRawLibrary> Items}) =
|
||||
_EmbyRawLibraries;
|
||||
|
||||
factory EmbyRawLibraries.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyRawLibrariesFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawMediaStream with _$EmbyRawMediaStream {
|
||||
const factory EmbyRawMediaStream({
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index,
|
||||
@JsonKey(includeIfNull: false) String? Type,
|
||||
@JsonKey(includeIfNull: false) String? Language,
|
||||
@JsonKey(includeIfNull: false) String? Codec,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels,
|
||||
@JsonKey(includeIfNull: false) String? DisplayTitle,
|
||||
@JsonKey(includeIfNull: false) String? Title,
|
||||
@JsonKey(includeIfNull: false) bool? IsDefault,
|
||||
@JsonKey(includeIfNull: false) bool? IsForced,
|
||||
@JsonKey(includeIfNull: false) bool? IsExternal,
|
||||
@JsonKey(includeIfNull: false) String? DeliveryMethod,
|
||||
@JsonKey(includeIfNull: false) String? DeliveryUrl,
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@Default(<String, dynamic>{})
|
||||
Map<String, dynamic> extra,
|
||||
}) = _EmbyRawMediaStream;
|
||||
|
||||
factory EmbyRawMediaStream.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyRawMediaStreamFromJson(json).copyWith(extra: json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawMediaSource with _$EmbyRawMediaSource {
|
||||
const factory EmbyRawMediaSource({
|
||||
@JsonKey(includeIfNull: false) String? Id,
|
||||
@JsonKey(includeIfNull: false) String? Name,
|
||||
@JsonKey(includeIfNull: false) String? Container,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false)
|
||||
int? DefaultSubtitleStreamIndex,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false)
|
||||
int? DefaultAudioStreamIndex,
|
||||
@JsonKey(includeIfNull: false) List<EmbyRawMediaStream>? MediaStreams,
|
||||
}) = _EmbyRawMediaSource;
|
||||
|
||||
factory EmbyRawMediaSource.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyRawMediaSourceFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LibraryLatestReq with _$LibraryLatestReq {
|
||||
const LibraryLatestReq._();
|
||||
const factory LibraryLatestReq({required String parentId, int? limit}) =
|
||||
_LibraryLatestReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'parentId': parentId,
|
||||
if (limit != null) 'limit': limit,
|
||||
};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LibraryLatestRes with _$LibraryLatestRes {
|
||||
const factory LibraryLatestRes({
|
||||
required String parentId,
|
||||
required List<EmbyRawItem> items,
|
||||
@Default(0) int totalCount,
|
||||
}) = _LibraryLatestRes;
|
||||
|
||||
factory LibraryLatestRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$LibraryLatestResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LibraryResumeRes with _$LibraryResumeRes {
|
||||
const factory LibraryResumeRes({required List<EmbyRawItem> Items}) =
|
||||
_LibraryResumeRes;
|
||||
|
||||
factory LibraryResumeRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$LibraryResumeResFromJson(json);
|
||||
}
|
||||
|
||||
typedef LibraryViewsRes = EmbyRawLibraries;
|
||||
|
||||
@freezed
|
||||
abstract class MediaDetailReq with _$MediaDetailReq {
|
||||
const MediaDetailReq._();
|
||||
const factory MediaDetailReq({required String itemId}) = _MediaDetailReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {'itemId': itemId};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawMediaDetailSeriesExtra
|
||||
with _$EmbyRawMediaDetailSeriesExtra {
|
||||
const factory EmbyRawMediaDetailSeriesExtra({
|
||||
required List<EmbyRawItem> nextUp,
|
||||
required List<EmbyRawSeason> seasons,
|
||||
}) = _EmbyRawMediaDetailSeriesExtra;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyRawMediaDetailSeriesContext
|
||||
with _$EmbyRawMediaDetailSeriesContext {
|
||||
const factory EmbyRawMediaDetailSeriesContext({
|
||||
required EmbyRawItem seriesBase,
|
||||
required EmbyRawMediaDetailSeriesExtra seriesExtra,
|
||||
}) = _EmbyRawMediaDetailSeriesContext;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MediaDetailRes with _$MediaDetailRes {
|
||||
const factory MediaDetailRes({
|
||||
required EmbyRawItem base,
|
||||
EmbyRawMediaDetailSeriesExtra? seriesExtra,
|
||||
EmbyRawMediaDetailSeriesContext? seriesContext,
|
||||
List<EmbyRawItem>? similarItems,
|
||||
List<EmbyRawItem>? boxSetItems,
|
||||
}) = _MediaDetailRes;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class FavoriteSetReq with _$FavoriteSetReq {
|
||||
const FavoriteSetReq._();
|
||||
const factory FavoriteSetReq({
|
||||
required String itemId,
|
||||
required bool isFavorite,
|
||||
}) = _FavoriteSetReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {'itemId': itemId, 'isFavorite': isFavorite};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class FavoriteSetRes with _$FavoriteSetRes {
|
||||
const factory FavoriteSetRes({
|
||||
required String itemId,
|
||||
required bool isFavorite,
|
||||
}) = _FavoriteSetRes;
|
||||
|
||||
factory FavoriteSetRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$FavoriteSetResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PlayedSetReq with _$PlayedSetReq {
|
||||
const PlayedSetReq._();
|
||||
const factory PlayedSetReq({required String itemId, required bool played}) =
|
||||
_PlayedSetReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {'itemId': itemId, 'played': played};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PlayedSetRes with _$PlayedSetRes {
|
||||
const factory PlayedSetRes({required String itemId, required bool played}) =
|
||||
_PlayedSetRes;
|
||||
|
||||
factory PlayedSetRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlayedSetResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class HideFromResumeReq with _$HideFromResumeReq {
|
||||
const HideFromResumeReq._();
|
||||
const factory HideFromResumeReq({
|
||||
required String itemId,
|
||||
required bool hide,
|
||||
}) = _HideFromResumeReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {'itemId': itemId, 'hide': hide};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class HideFromResumeRes with _$HideFromResumeRes {
|
||||
const factory HideFromResumeRes({
|
||||
required String itemId,
|
||||
required bool hide,
|
||||
}) = _HideFromResumeRes;
|
||||
|
||||
factory HideFromResumeRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$HideFromResumeResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MediaEpisodesReq with _$MediaEpisodesReq {
|
||||
const MediaEpisodesReq._();
|
||||
const factory MediaEpisodesReq({
|
||||
required String seriesId,
|
||||
required String seasonId,
|
||||
}) = _MediaEpisodesReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {'seriesId': seriesId, 'seasonId': seasonId};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MediaEpisodesRes with _$MediaEpisodesRes {
|
||||
const factory MediaEpisodesRes({
|
||||
required String seriesId,
|
||||
required String seasonId,
|
||||
required List<EmbyRawItem> items,
|
||||
}) = _MediaEpisodesRes;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SeriesSeasonsReq with _$SeriesSeasonsReq {
|
||||
const SeriesSeasonsReq._();
|
||||
const factory SeriesSeasonsReq({required String seriesId}) =
|
||||
_SeriesSeasonsReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {'seriesId': seriesId};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SeriesSeasonsRes with _$SeriesSeasonsRes {
|
||||
const factory SeriesSeasonsRes({
|
||||
required String seriesId,
|
||||
required List<EmbyRawSeason> items,
|
||||
}) = _SeriesSeasonsRes;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SimilarItemsReq with _$SimilarItemsReq {
|
||||
const SimilarItemsReq._();
|
||||
const factory SimilarItemsReq({required String itemId, int? limit}) =
|
||||
_SimilarItemsReq;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'itemId': itemId,
|
||||
if (limit != null) 'limit': limit,
|
||||
};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SimilarItemsRes with _$SimilarItemsRes {
|
||||
const factory SimilarItemsRes({
|
||||
required String itemId,
|
||||
required List<EmbyRawItem> items,
|
||||
}) = _SimilarItemsRes;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class AdditionalPartsReq with _$AdditionalPartsReq {
|
||||
const factory AdditionalPartsReq({required String itemId}) =
|
||||
_AdditionalPartsReq;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class AdditionalPartsRes with _$AdditionalPartsRes {
|
||||
const factory AdditionalPartsRes({
|
||||
required String itemId,
|
||||
required List<EmbyRawItem> items,
|
||||
}) = _AdditionalPartsRes;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CrossServerSourceCard with _$CrossServerSourceCard {
|
||||
const factory CrossServerSourceCard({
|
||||
required String serverId,
|
||||
required String serverName,
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
required String userId,
|
||||
required String landingItemId,
|
||||
required String mediaSourceId,
|
||||
required EmbyRawItem item,
|
||||
required EmbyRawMediaSource mediaSource,
|
||||
MediaQualityInfo? quality,
|
||||
}) = _CrossServerSourceCard;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MediaQualityInfo with _$MediaQualityInfo {
|
||||
const MediaQualityInfo._();
|
||||
const factory MediaQualityInfo({
|
||||
int? height,
|
||||
int? width,
|
||||
String? videoCodec,
|
||||
String? container,
|
||||
String? videoRange,
|
||||
String? videoRangeType,
|
||||
int? bitDepth,
|
||||
double? fps,
|
||||
}) = _MediaQualityInfo;
|
||||
|
||||
factory MediaQualityInfo.fromMediaSource(EmbyRawMediaSource source) {
|
||||
final streams = source.MediaStreams ?? const [];
|
||||
for (final stream in streams) {
|
||||
if (stream.Type != 'Video') continue;
|
||||
final ex = stream.extra;
|
||||
final realFps = ex['RealFrameRate'] as num?;
|
||||
final avgFps = ex['AverageFrameRate'] as num?;
|
||||
return MediaQualityInfo(
|
||||
height: (ex['Height'] as num?)?.toInt() ?? source.Height,
|
||||
width: (ex['Width'] as num?)?.toInt() ?? source.Width,
|
||||
videoCodec: stream.Codec,
|
||||
container: source.Container,
|
||||
videoRange: ex['VideoRange'] as String?,
|
||||
videoRangeType: ex['VideoRangeType'] as String?,
|
||||
bitDepth: (ex['BitDepth'] as num?)?.toInt(),
|
||||
fps: realFps?.toDouble() ?? avgFps?.toDouble(),
|
||||
);
|
||||
}
|
||||
final nameFallback = fromSourceName(
|
||||
source.Name,
|
||||
fallbackHeight: source.Height,
|
||||
container: source.Container,
|
||||
);
|
||||
if (nameFallback != null) return nameFallback;
|
||||
return MediaQualityInfo(
|
||||
height: source.Height,
|
||||
width: source.Width,
|
||||
container: source.Container,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
static MediaQualityInfo? fromSourceName(
|
||||
String? name, {
|
||||
int? fallbackHeight,
|
||||
String? container,
|
||||
}) {
|
||||
final height = _heightFromName(name) ?? fallbackHeight;
|
||||
final range = _rangeFromName(name);
|
||||
if (height == null && range == null) return null;
|
||||
return MediaQualityInfo(
|
||||
height: height,
|
||||
container: container,
|
||||
videoRange: range,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _heightFromName(String? name) {
|
||||
if (name == null || name.isEmpty) return null;
|
||||
final lower = name.toLowerCase();
|
||||
if (RegExp(r'(?:^|[^0-9])2160(?![0-9])p?|\b4k\b|\buhd\b').hasMatch(lower)) {
|
||||
return 2160;
|
||||
}
|
||||
if (RegExp(r'(?:^|[^0-9])1440(?![0-9])p?|\b2k\b').hasMatch(lower)) {
|
||||
return 1440;
|
||||
}
|
||||
if (RegExp(r'(?:^|[^0-9])1080(?![0-9])p?|\bfhd\b').hasMatch(lower)) {
|
||||
return 1080;
|
||||
}
|
||||
if (RegExp(r'(?:^|[^0-9])720(?![0-9])p?|\bhd\b').hasMatch(lower)) {
|
||||
return 720;
|
||||
}
|
||||
if (RegExp(r'(?:^|[^0-9])480(?![0-9])p?').hasMatch(lower)) return 480;
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _rangeFromName(String? name) {
|
||||
if (name == null || name.isEmpty) return null;
|
||||
final lower = name.toLowerCase();
|
||||
if (RegExp(r'\bdv\b|dolby[\s._-]*vision|dovi').hasMatch(lower)) {
|
||||
return 'DolbyVision';
|
||||
}
|
||||
if (RegExp(r'hdr10\+|hdr10plus').hasMatch(lower)) return 'HDR10Plus';
|
||||
if (RegExp(r'\bhlg\b').hasMatch(lower)) return 'HLG';
|
||||
if (RegExp(r'hdr10').hasMatch(lower)) return 'HDR10';
|
||||
if (RegExp(r'\bhdr\b').hasMatch(lower)) return 'HDR';
|
||||
return null;
|
||||
}
|
||||
|
||||
String get resolutionLabel {
|
||||
final h = height;
|
||||
if (h == null) return '';
|
||||
if (h >= 2160) return '4K';
|
||||
if (h >= 1440) return '2K';
|
||||
if (h >= 1080) return '1080P';
|
||||
if (h >= 720) return '720P';
|
||||
return '${h}P';
|
||||
}
|
||||
|
||||
String get dynamicRangeLabel {
|
||||
final typeKnown = _canonicalDynamicRange(videoRangeType);
|
||||
if (typeKnown != null) return typeKnown;
|
||||
final rangeKnown = _canonicalDynamicRange(videoRange);
|
||||
if (rangeKnown != null) return rangeKnown;
|
||||
final typeRaw = _rawDynamicRange(videoRangeType);
|
||||
if (typeRaw != null) return typeRaw;
|
||||
final rangeRaw = _rawDynamicRange(videoRange);
|
||||
if (rangeRaw != null) return rangeRaw;
|
||||
return 'SDR';
|
||||
}
|
||||
|
||||
|
||||
static String? _canonicalDynamicRange(String? raw) {
|
||||
if (raw == null) return null;
|
||||
final compact = raw.replaceAll(' ', '');
|
||||
if (compact.isEmpty) return null;
|
||||
final upper = compact.toUpperCase();
|
||||
if (upper.contains('DOVI') || upper.contains('DOLBYVISION')) {
|
||||
return 'Dolby Vision';
|
||||
}
|
||||
if (upper == 'HDR10PLUS' || upper == 'HDR10+') return 'HDR10+';
|
||||
if (upper == 'HDR10') return 'HDR10';
|
||||
if (upper == 'HLG') return 'HLG';
|
||||
if (upper == 'HDR') return 'HDR';
|
||||
if (upper == 'SDR') return 'SDR';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
static String? _rawDynamicRange(String? raw) {
|
||||
if (raw == null) return null;
|
||||
final trimmed = raw.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
bool get isDolbyVision => dynamicRangeLabel == 'Dolby Vision';
|
||||
bool get isHDR => dynamicRangeLabel != 'SDR';
|
||||
|
||||
String get codecLabel {
|
||||
final c = videoCodec?.toLowerCase();
|
||||
if (c == null || c.isEmpty) return '';
|
||||
if (c == 'hevc' || c == 'h265') return 'H265';
|
||||
if (c == 'h264' || c == 'avc') return 'H264';
|
||||
if (c == 'av1') return 'AV1';
|
||||
if (c == 'vp9') return 'VP9';
|
||||
return c.toUpperCase();
|
||||
}
|
||||
|
||||
String get bitDepthLabel {
|
||||
if (bitDepth == null) return '';
|
||||
return '${bitDepth}bit';
|
||||
}
|
||||
|
||||
String get fpsLabel {
|
||||
if (fps == null) return '';
|
||||
final f = fps!;
|
||||
if ((f - f.roundToDouble()).abs() < 0.1) return '${f.round()}fps';
|
||||
return '${f.toStringAsFixed(1)}fps';
|
||||
}
|
||||
|
||||
String get detailLine {
|
||||
final parts = <String>[
|
||||
codecLabel,
|
||||
bitDepthLabel,
|
||||
fpsLabel,
|
||||
].where((s) => s.isNotEmpty).toList();
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
String get summary {
|
||||
final parts = <String>[];
|
||||
final res = resolutionLabel;
|
||||
if (res.isNotEmpty) parts.add(res);
|
||||
final dr = dynamicRangeLabel;
|
||||
if (dr.isNotEmpty) parts.add(dr);
|
||||
if (codecLabel.isNotEmpty) parts.add(codecLabel);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CrossServerSearchReq with _$CrossServerSearchReq {
|
||||
const factory CrossServerSearchReq({
|
||||
required String anchorType,
|
||||
required String itemName,
|
||||
required Map<String, String> providerIds,
|
||||
Map<String, String>? seriesProviderIds,
|
||||
int? parentIndexNumber,
|
||||
int? indexNumber,
|
||||
|
||||
|
||||
String? excludedServerId,
|
||||
}) = _CrossServerSearchReq;
|
||||
}
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class TmdbAvailabilityReq with _$TmdbAvailabilityReq {
|
||||
const factory TmdbAvailabilityReq({
|
||||
required String tmdbId,
|
||||
required String mediaType,
|
||||
|
||||
|
||||
String? imdbId,
|
||||
}) = _TmdbAvailabilityReq;
|
||||
}
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class TmdbLibraryHit with _$TmdbLibraryHit {
|
||||
const factory TmdbLibraryHit({
|
||||
required String serverId,
|
||||
required String serverName,
|
||||
required String itemId,
|
||||
required String itemType,
|
||||
required String name,
|
||||
int? productionYear,
|
||||
|
||||
|
||||
int? playbackPositionTicks,
|
||||
|
||||
|
||||
int? runTimeTicks,
|
||||
}) = _TmdbLibraryHit;
|
||||
}
|
||||
|
||||
enum SortOrder {
|
||||
ascending('Ascending'),
|
||||
descending('Descending');
|
||||
|
||||
final String value;
|
||||
const SortOrder(this.value);
|
||||
|
||||
static SortOrder? fromString(String? value) {
|
||||
if (value == null) return null;
|
||||
return SortOrder.values.firstWhere(
|
||||
(e) => e.value == value,
|
||||
orElse: () => SortOrder.ascending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LibraryItemsReq with _$LibraryItemsReq {
|
||||
const factory LibraryItemsReq({
|
||||
String? parentId,
|
||||
String? includeItemTypes,
|
||||
List<String>? genreIds,
|
||||
String? searchTerm,
|
||||
String? anyProviderIdEquals,
|
||||
String? fields,
|
||||
String? enableImageTypes,
|
||||
bool? groupProgramsBySeries,
|
||||
int? imageTypeLimit,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
bool? recursive,
|
||||
String? sortBy,
|
||||
SortOrder? sortOrder,
|
||||
}) = _LibraryItemsReq;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LibraryItemsRes with _$LibraryItemsRes {
|
||||
const factory LibraryItemsRes({
|
||||
required String parentId,
|
||||
required List<EmbyRawItem> items,
|
||||
}) = _LibraryItemsRes;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ItemCountsRes with _$ItemCountsRes {
|
||||
const factory ItemCountsRes({
|
||||
@JsonKey(name: 'MovieCount', fromJson: _intOrZero) required int movieCount,
|
||||
@JsonKey(name: 'EpisodeCount', fromJson: _intOrZero)
|
||||
required int episodeCount,
|
||||
}) = _ItemCountsRes;
|
||||
|
||||
factory ItemCountsRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$ItemCountsResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class GlobalSearchReq with _$GlobalSearchReq {
|
||||
const factory GlobalSearchReq({
|
||||
required String keyword,
|
||||
String? includeItemTypes,
|
||||
int? limitPerServer,
|
||||
String? serverId,
|
||||
}) = _GlobalSearchReq;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class GlobalSearchServerResult with _$GlobalSearchServerResult {
|
||||
const factory GlobalSearchServerResult({
|
||||
required String serverId,
|
||||
required String serverName,
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
required String userId,
|
||||
required List<EmbyRawItem> items,
|
||||
}) = _GlobalSearchServerResult;
|
||||
}
|
||||
|
||||
int? _intOrNull(Object? value) => (value as num?)?.toInt();
|
||||
double? _doubleOrNull(Object? value) => (value as num?)?.toDouble();
|
||||
int _intOrZero(Object? value) => (value as num?)?.toInt() ?? 0;
|
||||
|
||||
|
||||
List<EmbyRawMediaSource> mediaSourcesOfItem(EmbyRawItem? item) {
|
||||
final raw = item?.extra['MediaSources'];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(EmbyRawMediaSource.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
|
||||
Map<String, String> providerIdsOfItem(EmbyRawItem item) {
|
||||
final raw = item.extra['ProviderIds'];
|
||||
if (raw is! Map) return const {};
|
||||
final ids = <String, String>{};
|
||||
for (final entry in raw.entries) {
|
||||
final key = entry.key?.toString() ?? '';
|
||||
final value = entry.value?.toString() ?? '';
|
||||
if (key.isNotEmpty && value.isNotEmpty) ids[key] = value;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Map<String, String> normalizeProviderIds(Map<String, String> raw) {
|
||||
if (raw.isEmpty) return const {};
|
||||
final out = <String, String>{};
|
||||
for (final e in raw.entries) {
|
||||
final k = e.key.trim().toLowerCase();
|
||||
final v = e.value.trim();
|
||||
if (k.isNotEmpty && v.isNotEmpty) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Map<String, String> normalizedProviderIdsOfItem(EmbyRawItem item) =>
|
||||
normalizeProviderIds(providerIdsOfItem(item));
|
||||
|
||||
Map<String, String>? _stringMapOrNull(Object? value) {
|
||||
if (value is! Map) return null;
|
||||
return value.map((k, v) => MapEntry(k as String, v as String));
|
||||
}
|
||||
|
||||
List<String>? _stringListOrNull(Object? value) {
|
||||
if (value is! List) return null;
|
||||
return value.cast<String>();
|
||||
}
|
||||
Generated
+10615
File diff suppressed because it is too large
Load Diff
Generated
+250
@@ -0,0 +1,250 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'library.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_EmbyRawUserData _$EmbyRawUserDataFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyRawUserData(
|
||||
PlaybackPositionTicks: _intOrNull(json['PlaybackPositionTicks']),
|
||||
PlayCount: _intOrNull(json['PlayCount']),
|
||||
UnplayedItemCount: _intOrNull(json['UnplayedItemCount']),
|
||||
IsFavorite: json['IsFavorite'] as bool?,
|
||||
Played: json['Played'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyRawUserDataToJson(_EmbyRawUserData instance) =>
|
||||
<String, dynamic>{
|
||||
'PlaybackPositionTicks': ?instance.PlaybackPositionTicks,
|
||||
'PlayCount': ?instance.PlayCount,
|
||||
'UnplayedItemCount': ?instance.UnplayedItemCount,
|
||||
'IsFavorite': ?instance.IsFavorite,
|
||||
'Played': ?instance.Played,
|
||||
};
|
||||
|
||||
_EmbyRawItem _$EmbyRawItemFromJson(Map<String, dynamic> json) => _EmbyRawItem(
|
||||
Id: json['Id'] as String,
|
||||
Name: json['Name'] as String,
|
||||
Type: json['Type'] as String?,
|
||||
Overview: json['Overview'] as String?,
|
||||
ProductionYear: _intOrNull(json['ProductionYear']),
|
||||
CommunityRating: _doubleOrNull(json['CommunityRating']),
|
||||
PremiereDate: json['PremiereDate'] as String?,
|
||||
SeriesName: json['SeriesName'] as String?,
|
||||
SeriesId: json['SeriesId'] as String?,
|
||||
SeasonId: json['SeasonId'] as String?,
|
||||
SeasonName: json['SeasonName'] as String?,
|
||||
IndexNumber: _intOrNull(json['IndexNumber']),
|
||||
ParentId: json['ParentId'] as String?,
|
||||
PrimaryImageTag: json['PrimaryImageTag'] as String?,
|
||||
ImageTags: _stringMapOrNull(json['ImageTags']),
|
||||
BackdropImageTags: _stringListOrNull(json['BackdropImageTags']),
|
||||
RunTimeTicks: _intOrNull(json['RunTimeTicks']),
|
||||
UserData: json['UserData'] == null
|
||||
? null
|
||||
: EmbyRawUserData.fromJson(json['UserData'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyRawItemToJson(_EmbyRawItem instance) =>
|
||||
<String, dynamic>{
|
||||
'Id': instance.Id,
|
||||
'Name': instance.Name,
|
||||
'Type': ?instance.Type,
|
||||
'Overview': ?instance.Overview,
|
||||
'ProductionYear': ?instance.ProductionYear,
|
||||
'CommunityRating': ?instance.CommunityRating,
|
||||
'PremiereDate': ?instance.PremiereDate,
|
||||
'SeriesName': ?instance.SeriesName,
|
||||
'SeriesId': ?instance.SeriesId,
|
||||
'SeasonId': ?instance.SeasonId,
|
||||
'SeasonName': ?instance.SeasonName,
|
||||
'IndexNumber': ?instance.IndexNumber,
|
||||
'ParentId': ?instance.ParentId,
|
||||
'PrimaryImageTag': ?instance.PrimaryImageTag,
|
||||
'ImageTags': ?instance.ImageTags,
|
||||
'BackdropImageTags': ?instance.BackdropImageTags,
|
||||
'RunTimeTicks': ?instance.RunTimeTicks,
|
||||
'UserData': ?instance.UserData,
|
||||
};
|
||||
|
||||
_EmbyRawSeason _$EmbyRawSeasonFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyRawSeason(
|
||||
Id: json['Id'] as String,
|
||||
Name: json['Name'] as String,
|
||||
IndexNumber: _intOrNull(json['IndexNumber']),
|
||||
ProductionYear: _intOrNull(json['ProductionYear']),
|
||||
ChildCount: _intOrNull(json['ChildCount']),
|
||||
PrimaryImageTag: json['PrimaryImageTag'] as String?,
|
||||
ImageTags: _stringMapOrNull(json['ImageTags']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyRawSeasonToJson(_EmbyRawSeason instance) =>
|
||||
<String, dynamic>{
|
||||
'Id': instance.Id,
|
||||
'Name': instance.Name,
|
||||
'IndexNumber': ?instance.IndexNumber,
|
||||
'ProductionYear': ?instance.ProductionYear,
|
||||
'ChildCount': ?instance.ChildCount,
|
||||
'PrimaryImageTag': ?instance.PrimaryImageTag,
|
||||
'ImageTags': ?instance.ImageTags,
|
||||
};
|
||||
|
||||
_EmbyRawLibrary _$EmbyRawLibraryFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyRawLibrary(
|
||||
Id: json['Id'] as String,
|
||||
Name: json['Name'] as String,
|
||||
CollectionType: json['CollectionType'] as String?,
|
||||
ImageTags: _stringMapOrNull(json['ImageTags']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyRawLibraryToJson(_EmbyRawLibrary instance) =>
|
||||
<String, dynamic>{
|
||||
'Id': instance.Id,
|
||||
'Name': instance.Name,
|
||||
'CollectionType': ?instance.CollectionType,
|
||||
'ImageTags': ?instance.ImageTags,
|
||||
};
|
||||
|
||||
_EmbyRawLibraries _$EmbyRawLibrariesFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyRawLibraries(
|
||||
Items: (json['Items'] as List<dynamic>)
|
||||
.map((e) => EmbyRawLibrary.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyRawLibrariesToJson(_EmbyRawLibraries instance) =>
|
||||
<String, dynamic>{'Items': instance.Items};
|
||||
|
||||
_EmbyRawMediaStream _$EmbyRawMediaStreamFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyRawMediaStream(
|
||||
Index: _intOrNull(json['Index']),
|
||||
Type: json['Type'] as String?,
|
||||
Language: json['Language'] as String?,
|
||||
Codec: json['Codec'] as String?,
|
||||
Channels: _intOrNull(json['Channels']),
|
||||
DisplayTitle: json['DisplayTitle'] as String?,
|
||||
Title: json['Title'] as String?,
|
||||
IsDefault: json['IsDefault'] as bool?,
|
||||
IsForced: json['IsForced'] as bool?,
|
||||
IsExternal: json['IsExternal'] as bool?,
|
||||
DeliveryMethod: json['DeliveryMethod'] as String?,
|
||||
DeliveryUrl: json['DeliveryUrl'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyRawMediaStreamToJson(_EmbyRawMediaStream instance) =>
|
||||
<String, dynamic>{
|
||||
'Index': ?instance.Index,
|
||||
'Type': ?instance.Type,
|
||||
'Language': ?instance.Language,
|
||||
'Codec': ?instance.Codec,
|
||||
'Channels': ?instance.Channels,
|
||||
'DisplayTitle': ?instance.DisplayTitle,
|
||||
'Title': ?instance.Title,
|
||||
'IsDefault': ?instance.IsDefault,
|
||||
'IsForced': ?instance.IsForced,
|
||||
'IsExternal': ?instance.IsExternal,
|
||||
'DeliveryMethod': ?instance.DeliveryMethod,
|
||||
'DeliveryUrl': ?instance.DeliveryUrl,
|
||||
};
|
||||
|
||||
_EmbyRawMediaSource _$EmbyRawMediaSourceFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyRawMediaSource(
|
||||
Id: json['Id'] as String?,
|
||||
Name: json['Name'] as String?,
|
||||
Container: json['Container'] as String?,
|
||||
Bitrate: _intOrNull(json['Bitrate']),
|
||||
Size: _intOrNull(json['Size']),
|
||||
Width: _intOrNull(json['Width']),
|
||||
Height: _intOrNull(json['Height']),
|
||||
DefaultSubtitleStreamIndex: _intOrNull(
|
||||
json['DefaultSubtitleStreamIndex'],
|
||||
),
|
||||
DefaultAudioStreamIndex: _intOrNull(json['DefaultAudioStreamIndex']),
|
||||
MediaStreams: (json['MediaStreams'] as List<dynamic>?)
|
||||
?.map((e) => EmbyRawMediaStream.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyRawMediaSourceToJson(_EmbyRawMediaSource instance) =>
|
||||
<String, dynamic>{
|
||||
'Id': ?instance.Id,
|
||||
'Name': ?instance.Name,
|
||||
'Container': ?instance.Container,
|
||||
'Bitrate': ?instance.Bitrate,
|
||||
'Size': ?instance.Size,
|
||||
'Width': ?instance.Width,
|
||||
'Height': ?instance.Height,
|
||||
'DefaultSubtitleStreamIndex': ?instance.DefaultSubtitleStreamIndex,
|
||||
'DefaultAudioStreamIndex': ?instance.DefaultAudioStreamIndex,
|
||||
'MediaStreams': ?instance.MediaStreams,
|
||||
};
|
||||
|
||||
_LibraryLatestRes _$LibraryLatestResFromJson(Map<String, dynamic> json) =>
|
||||
_LibraryLatestRes(
|
||||
parentId: json['parentId'] as String,
|
||||
items: (json['items'] as List<dynamic>)
|
||||
.map((e) => EmbyRawItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
totalCount: (json['totalCount'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LibraryLatestResToJson(_LibraryLatestRes instance) =>
|
||||
<String, dynamic>{
|
||||
'parentId': instance.parentId,
|
||||
'items': instance.items,
|
||||
'totalCount': instance.totalCount,
|
||||
};
|
||||
|
||||
_LibraryResumeRes _$LibraryResumeResFromJson(Map<String, dynamic> json) =>
|
||||
_LibraryResumeRes(
|
||||
Items: (json['Items'] as List<dynamic>)
|
||||
.map((e) => EmbyRawItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LibraryResumeResToJson(_LibraryResumeRes instance) =>
|
||||
<String, dynamic>{'Items': instance.Items};
|
||||
|
||||
_FavoriteSetRes _$FavoriteSetResFromJson(Map<String, dynamic> json) =>
|
||||
_FavoriteSetRes(
|
||||
itemId: json['itemId'] as String,
|
||||
isFavorite: json['isFavorite'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$FavoriteSetResToJson(_FavoriteSetRes instance) =>
|
||||
<String, dynamic>{
|
||||
'itemId': instance.itemId,
|
||||
'isFavorite': instance.isFavorite,
|
||||
};
|
||||
|
||||
_PlayedSetRes _$PlayedSetResFromJson(Map<String, dynamic> json) =>
|
||||
_PlayedSetRes(
|
||||
itemId: json['itemId'] as String,
|
||||
played: json['played'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlayedSetResToJson(_PlayedSetRes instance) =>
|
||||
<String, dynamic>{'itemId': instance.itemId, 'played': instance.played};
|
||||
|
||||
_HideFromResumeRes _$HideFromResumeResFromJson(Map<String, dynamic> json) =>
|
||||
_HideFromResumeRes(
|
||||
itemId: json['itemId'] as String,
|
||||
hide: json['hide'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$HideFromResumeResToJson(_HideFromResumeRes instance) =>
|
||||
<String, dynamic>{'itemId': instance.itemId, 'hide': instance.hide};
|
||||
|
||||
_ItemCountsRes _$ItemCountsResFromJson(Map<String, dynamic> json) =>
|
||||
_ItemCountsRes(
|
||||
movieCount: _intOrZero(json['MovieCount']),
|
||||
episodeCount: _intOrZero(json['EpisodeCount']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ItemCountsResToJson(_ItemCountsRes instance) =>
|
||||
<String, dynamic>{
|
||||
'MovieCount': instance.movieCount,
|
||||
'EpisodeCount': instance.episodeCount,
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'library.dart';
|
||||
|
||||
part 'playback.freezed.dart';
|
||||
part 'playback.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PlaybackRequestPayload with _$PlaybackRequestPayload {
|
||||
const factory PlaybackRequestPayload({
|
||||
required String itemId,
|
||||
@JsonKey(includeIfNull: false) String? itemType,
|
||||
@JsonKey(includeIfNull: false) String? mediaSourceId,
|
||||
@JsonKey(includeIfNull: false) int? startPositionTicks,
|
||||
@Default(false) bool playFromStart,
|
||||
}) = _PlaybackRequestPayload;
|
||||
|
||||
factory PlaybackRequestPayload.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlaybackRequestPayloadFromJson(json);
|
||||
}
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class EmbySubtitleTrack with _$EmbySubtitleTrack {
|
||||
const factory EmbySubtitleTrack({
|
||||
@JsonKey(fromJson: _intRequired) required int index,
|
||||
@JsonKey(includeIfNull: false) String? title,
|
||||
@JsonKey(includeIfNull: false) String? language,
|
||||
@JsonKey(fromJson: _boolOrFalse) @Default(false) bool isDefault,
|
||||
@JsonKey(fromJson: _boolOrFalse) @Default(false) bool isForced,
|
||||
@JsonKey(fromJson: _boolOrFalse) @Default(false) bool isExternal,
|
||||
@JsonKey(includeIfNull: false) String? deliveryUrl,
|
||||
@JsonKey(includeIfNull: false) String? codec,
|
||||
}) = _EmbySubtitleTrack;
|
||||
|
||||
factory EmbySubtitleTrack.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbySubtitleTrackFromJson(json);
|
||||
}
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class EmbyAudioTrack with _$EmbyAudioTrack {
|
||||
const factory EmbyAudioTrack({
|
||||
@JsonKey(fromJson: _intRequired) required int index,
|
||||
@JsonKey(includeIfNull: false) String? title,
|
||||
@JsonKey(includeIfNull: false) String? language,
|
||||
@JsonKey(includeIfNull: false) String? codec,
|
||||
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels,
|
||||
@JsonKey(includeIfNull: false) String? displayTitle,
|
||||
@JsonKey(fromJson: _boolOrFalse) @Default(false) bool isDefault,
|
||||
}) = _EmbyAudioTrack;
|
||||
|
||||
factory EmbyAudioTrack.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyAudioTrackFromJson(json);
|
||||
}
|
||||
|
||||
int _intRequired(Object? value) => (value as num).toInt();
|
||||
|
||||
int? _intOrNull(Object? value) => (value as num?)?.toInt();
|
||||
|
||||
bool _boolOrFalse(Object? value) => (value as bool?) ?? false;
|
||||
|
||||
|
||||
class PlaybackRequestResult {
|
||||
|
||||
final String streamUrl;
|
||||
|
||||
|
||||
final String? container;
|
||||
|
||||
|
||||
final String? mimeType;
|
||||
|
||||
|
||||
final bool directStream;
|
||||
|
||||
|
||||
final EmbyRawItem item;
|
||||
|
||||
|
||||
final EmbyRawMediaSource? mediaSource;
|
||||
|
||||
|
||||
final double startPositionSeconds;
|
||||
|
||||
|
||||
final double? durationSeconds;
|
||||
|
||||
|
||||
final List<EmbySubtitleTrack> subtitles;
|
||||
|
||||
|
||||
final int? defaultSubtitleIndex;
|
||||
|
||||
|
||||
final List<EmbyAudioTrack> audioTracks;
|
||||
|
||||
|
||||
final int? defaultAudioStreamIndex;
|
||||
|
||||
|
||||
final String? playSessionId;
|
||||
|
||||
|
||||
final String playMethod;
|
||||
|
||||
const PlaybackRequestResult({
|
||||
required this.streamUrl,
|
||||
this.container,
|
||||
this.mimeType,
|
||||
required this.directStream,
|
||||
required this.item,
|
||||
this.mediaSource,
|
||||
this.startPositionSeconds = 0,
|
||||
this.durationSeconds,
|
||||
this.subtitles = const [],
|
||||
this.defaultSubtitleIndex,
|
||||
this.audioTracks = const [],
|
||||
this.defaultAudioStreamIndex,
|
||||
this.playSessionId,
|
||||
this.playMethod = 'DirectStream',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class PlaybackReportPayload {
|
||||
final String itemId;
|
||||
final String? mediaSourceId;
|
||||
final String? playSessionId;
|
||||
final int positionTicks;
|
||||
final int? runTimeTicks;
|
||||
final String playMethod;
|
||||
final bool canSeek;
|
||||
final bool isPaused;
|
||||
final bool isMuted;
|
||||
final double playbackRate;
|
||||
final int playlistIndex;
|
||||
final int playlistLength;
|
||||
final String repeatMode;
|
||||
final String? eventName;
|
||||
|
||||
const PlaybackReportPayload({
|
||||
required this.itemId,
|
||||
this.mediaSourceId,
|
||||
this.playSessionId,
|
||||
required this.positionTicks,
|
||||
this.runTimeTicks,
|
||||
this.playMethod = 'DirectStream',
|
||||
this.canSeek = true,
|
||||
this.isPaused = false,
|
||||
this.isMuted = false,
|
||||
this.playbackRate = 1,
|
||||
this.playlistIndex = 0,
|
||||
this.playlistLength = 1,
|
||||
this.repeatMode = 'RepeatNone',
|
||||
this.eventName,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson({bool includeNowPlayingQueue = false}) => {
|
||||
'ItemId': itemId,
|
||||
if (mediaSourceId != null && mediaSourceId!.isNotEmpty)
|
||||
'MediaSourceId': mediaSourceId,
|
||||
if (playSessionId != null && playSessionId!.isNotEmpty)
|
||||
'PlaySessionId': playSessionId,
|
||||
'PositionTicks': positionTicks,
|
||||
if (runTimeTicks != null) 'RunTimeTicks': runTimeTicks,
|
||||
'CanSeek': canSeek,
|
||||
'PlayMethod': playMethod,
|
||||
'PlaylistIndex': playlistIndex,
|
||||
'PlaylistLength': playlistLength,
|
||||
'PlaybackRate': playbackRate,
|
||||
'IsMuted': isMuted,
|
||||
'IsPaused': isPaused,
|
||||
'RepeatMode': repeatMode,
|
||||
if (eventName != null && eventName!.isNotEmpty) 'EventName': eventName,
|
||||
if (includeNowPlayingQueue)
|
||||
'NowPlayingQueue': [
|
||||
{'Id': itemId, 'PlaylistItemId': 'playlistItem$playlistIndex'},
|
||||
],
|
||||
};
|
||||
}
|
||||
Generated
+854
@@ -0,0 +1,854 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'playback.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$PlaybackRequestPayload {
|
||||
|
||||
String get itemId;@JsonKey(includeIfNull: false) String? get itemType;@JsonKey(includeIfNull: false) String? get mediaSourceId;@JsonKey(includeIfNull: false) int? get startPositionTicks; bool get playFromStart;
|
||||
/// Create a copy of PlaybackRequestPayload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$PlaybackRequestPayloadCopyWith<PlaybackRequestPayload> get copyWith => _$PlaybackRequestPayloadCopyWithImpl<PlaybackRequestPayload>(this as PlaybackRequestPayload, _$identity);
|
||||
|
||||
/// Serializes this PlaybackRequestPayload to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PlaybackRequestPayload&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.itemType, itemType) || other.itemType == itemType)&&(identical(other.mediaSourceId, mediaSourceId) || other.mediaSourceId == mediaSourceId)&&(identical(other.startPositionTicks, startPositionTicks) || other.startPositionTicks == startPositionTicks)&&(identical(other.playFromStart, playFromStart) || other.playFromStart == playFromStart));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,itemId,itemType,mediaSourceId,startPositionTicks,playFromStart);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlaybackRequestPayload(itemId: $itemId, itemType: $itemType, mediaSourceId: $mediaSourceId, startPositionTicks: $startPositionTicks, playFromStart: $playFromStart)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $PlaybackRequestPayloadCopyWith<$Res> {
|
||||
factory $PlaybackRequestPayloadCopyWith(PlaybackRequestPayload value, $Res Function(PlaybackRequestPayload) _then) = _$PlaybackRequestPayloadCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String itemId,@JsonKey(includeIfNull: false) String? itemType,@JsonKey(includeIfNull: false) String? mediaSourceId,@JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$PlaybackRequestPayloadCopyWithImpl<$Res>
|
||||
implements $PlaybackRequestPayloadCopyWith<$Res> {
|
||||
_$PlaybackRequestPayloadCopyWithImpl(this._self, this._then);
|
||||
|
||||
final PlaybackRequestPayload _self;
|
||||
final $Res Function(PlaybackRequestPayload) _then;
|
||||
|
||||
/// Create a copy of PlaybackRequestPayload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? itemType = freezed,Object? mediaSourceId = freezed,Object? startPositionTicks = freezed,Object? playFromStart = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable
|
||||
as String,itemType: freezed == itemType ? _self.itemType : itemType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,mediaSourceId: freezed == mediaSourceId ? _self.mediaSourceId : mediaSourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,startPositionTicks: freezed == startPositionTicks ? _self.startPositionTicks : startPositionTicks // ignore: cast_nullable_to_non_nullable
|
||||
as int?,playFromStart: null == playFromStart ? _self.playFromStart : playFromStart // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [PlaybackRequestPayload].
|
||||
extension PlaybackRequestPayloadPatterns on PlaybackRequestPayload {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _PlaybackRequestPayload value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PlaybackRequestPayload() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _PlaybackRequestPayload value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PlaybackRequestPayload():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _PlaybackRequestPayload value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PlaybackRequestPayload() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String itemId, @JsonKey(includeIfNull: false) String? itemType, @JsonKey(includeIfNull: false) String? mediaSourceId, @JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PlaybackRequestPayload() when $default != null:
|
||||
return $default(_that.itemId,_that.itemType,_that.mediaSourceId,_that.startPositionTicks,_that.playFromStart);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String itemId, @JsonKey(includeIfNull: false) String? itemType, @JsonKey(includeIfNull: false) String? mediaSourceId, @JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PlaybackRequestPayload():
|
||||
return $default(_that.itemId,_that.itemType,_that.mediaSourceId,_that.startPositionTicks,_that.playFromStart);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String itemId, @JsonKey(includeIfNull: false) String? itemType, @JsonKey(includeIfNull: false) String? mediaSourceId, @JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PlaybackRequestPayload() when $default != null:
|
||||
return $default(_that.itemId,_that.itemType,_that.mediaSourceId,_that.startPositionTicks,_that.playFromStart);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _PlaybackRequestPayload implements PlaybackRequestPayload {
|
||||
const _PlaybackRequestPayload({required this.itemId, @JsonKey(includeIfNull: false) this.itemType, @JsonKey(includeIfNull: false) this.mediaSourceId, @JsonKey(includeIfNull: false) this.startPositionTicks, this.playFromStart = false});
|
||||
factory _PlaybackRequestPayload.fromJson(Map<String, dynamic> json) => _$PlaybackRequestPayloadFromJson(json);
|
||||
|
||||
@override final String itemId;
|
||||
@override@JsonKey(includeIfNull: false) final String? itemType;
|
||||
@override@JsonKey(includeIfNull: false) final String? mediaSourceId;
|
||||
@override@JsonKey(includeIfNull: false) final int? startPositionTicks;
|
||||
@override@JsonKey() final bool playFromStart;
|
||||
|
||||
/// Create a copy of PlaybackRequestPayload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$PlaybackRequestPayloadCopyWith<_PlaybackRequestPayload> get copyWith => __$PlaybackRequestPayloadCopyWithImpl<_PlaybackRequestPayload>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$PlaybackRequestPayloadToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PlaybackRequestPayload&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.itemType, itemType) || other.itemType == itemType)&&(identical(other.mediaSourceId, mediaSourceId) || other.mediaSourceId == mediaSourceId)&&(identical(other.startPositionTicks, startPositionTicks) || other.startPositionTicks == startPositionTicks)&&(identical(other.playFromStart, playFromStart) || other.playFromStart == playFromStart));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,itemId,itemType,mediaSourceId,startPositionTicks,playFromStart);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlaybackRequestPayload(itemId: $itemId, itemType: $itemType, mediaSourceId: $mediaSourceId, startPositionTicks: $startPositionTicks, playFromStart: $playFromStart)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$PlaybackRequestPayloadCopyWith<$Res> implements $PlaybackRequestPayloadCopyWith<$Res> {
|
||||
factory _$PlaybackRequestPayloadCopyWith(_PlaybackRequestPayload value, $Res Function(_PlaybackRequestPayload) _then) = __$PlaybackRequestPayloadCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String itemId,@JsonKey(includeIfNull: false) String? itemType,@JsonKey(includeIfNull: false) String? mediaSourceId,@JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$PlaybackRequestPayloadCopyWithImpl<$Res>
|
||||
implements _$PlaybackRequestPayloadCopyWith<$Res> {
|
||||
__$PlaybackRequestPayloadCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _PlaybackRequestPayload _self;
|
||||
final $Res Function(_PlaybackRequestPayload) _then;
|
||||
|
||||
/// Create a copy of PlaybackRequestPayload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? itemType = freezed,Object? mediaSourceId = freezed,Object? startPositionTicks = freezed,Object? playFromStart = null,}) {
|
||||
return _then(_PlaybackRequestPayload(
|
||||
itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable
|
||||
as String,itemType: freezed == itemType ? _self.itemType : itemType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,mediaSourceId: freezed == mediaSourceId ? _self.mediaSourceId : mediaSourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,startPositionTicks: freezed == startPositionTicks ? _self.startPositionTicks : startPositionTicks // ignore: cast_nullable_to_non_nullable
|
||||
as int?,playFromStart: null == playFromStart ? _self.playFromStart : playFromStart // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$EmbySubtitleTrack {
|
||||
|
||||
@JsonKey(fromJson: _intRequired) int get index;@JsonKey(includeIfNull: false) String? get title;@JsonKey(includeIfNull: false) String? get language;@JsonKey(fromJson: _boolOrFalse) bool get isDefault;@JsonKey(fromJson: _boolOrFalse) bool get isForced;@JsonKey(fromJson: _boolOrFalse) bool get isExternal;@JsonKey(includeIfNull: false) String? get deliveryUrl;@JsonKey(includeIfNull: false) String? get codec;
|
||||
/// Create a copy of EmbySubtitleTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$EmbySubtitleTrackCopyWith<EmbySubtitleTrack> get copyWith => _$EmbySubtitleTrackCopyWithImpl<EmbySubtitleTrack>(this as EmbySubtitleTrack, _$identity);
|
||||
|
||||
/// Serializes this EmbySubtitleTrack to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbySubtitleTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.deliveryUrl, deliveryUrl) || other.deliveryUrl == deliveryUrl)&&(identical(other.codec, codec) || other.codec == codec));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,index,title,language,isDefault,isForced,isExternal,deliveryUrl,codec);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'EmbySubtitleTrack(index: $index, title: $title, language: $language, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, deliveryUrl: $deliveryUrl, codec: $codec)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $EmbySubtitleTrackCopyWith<$Res> {
|
||||
factory $EmbySubtitleTrackCopyWith(EmbySubtitleTrack value, $Res Function(EmbySubtitleTrack) _then) = _$EmbySubtitleTrackCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(fromJson: _boolOrFalse) bool isDefault,@JsonKey(fromJson: _boolOrFalse) bool isForced,@JsonKey(fromJson: _boolOrFalse) bool isExternal,@JsonKey(includeIfNull: false) String? deliveryUrl,@JsonKey(includeIfNull: false) String? codec
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$EmbySubtitleTrackCopyWithImpl<$Res>
|
||||
implements $EmbySubtitleTrackCopyWith<$Res> {
|
||||
_$EmbySubtitleTrackCopyWithImpl(this._self, this._then);
|
||||
|
||||
final EmbySubtitleTrack _self;
|
||||
final $Res Function(EmbySubtitleTrack) _then;
|
||||
|
||||
/// Create a copy of EmbySubtitleTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? deliveryUrl = freezed,Object? codec = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable
|
||||
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
|
||||
as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable
|
||||
as bool,deliveryUrl: freezed == deliveryUrl ? _self.deliveryUrl : deliveryUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [EmbySubtitleTrack].
|
||||
extension EmbySubtitleTrackPatterns on EmbySubtitleTrack {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _EmbySubtitleTrack value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbySubtitleTrack() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _EmbySubtitleTrack value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbySubtitleTrack():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _EmbySubtitleTrack value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbySubtitleTrack() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(fromJson: _boolOrFalse) bool isDefault, @JsonKey(fromJson: _boolOrFalse) bool isForced, @JsonKey(fromJson: _boolOrFalse) bool isExternal, @JsonKey(includeIfNull: false) String? deliveryUrl, @JsonKey(includeIfNull: false) String? codec)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbySubtitleTrack() when $default != null:
|
||||
return $default(_that.index,_that.title,_that.language,_that.isDefault,_that.isForced,_that.isExternal,_that.deliveryUrl,_that.codec);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(fromJson: _boolOrFalse) bool isDefault, @JsonKey(fromJson: _boolOrFalse) bool isForced, @JsonKey(fromJson: _boolOrFalse) bool isExternal, @JsonKey(includeIfNull: false) String? deliveryUrl, @JsonKey(includeIfNull: false) String? codec) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbySubtitleTrack():
|
||||
return $default(_that.index,_that.title,_that.language,_that.isDefault,_that.isForced,_that.isExternal,_that.deliveryUrl,_that.codec);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(fromJson: _boolOrFalse) bool isDefault, @JsonKey(fromJson: _boolOrFalse) bool isForced, @JsonKey(fromJson: _boolOrFalse) bool isExternal, @JsonKey(includeIfNull: false) String? deliveryUrl, @JsonKey(includeIfNull: false) String? codec)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbySubtitleTrack() when $default != null:
|
||||
return $default(_that.index,_that.title,_that.language,_that.isDefault,_that.isForced,_that.isExternal,_that.deliveryUrl,_that.codec);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _EmbySubtitleTrack implements EmbySubtitleTrack {
|
||||
const _EmbySubtitleTrack({@JsonKey(fromJson: _intRequired) required this.index, @JsonKey(includeIfNull: false) this.title, @JsonKey(includeIfNull: false) this.language, @JsonKey(fromJson: _boolOrFalse) this.isDefault = false, @JsonKey(fromJson: _boolOrFalse) this.isForced = false, @JsonKey(fromJson: _boolOrFalse) this.isExternal = false, @JsonKey(includeIfNull: false) this.deliveryUrl, @JsonKey(includeIfNull: false) this.codec});
|
||||
factory _EmbySubtitleTrack.fromJson(Map<String, dynamic> json) => _$EmbySubtitleTrackFromJson(json);
|
||||
|
||||
@override@JsonKey(fromJson: _intRequired) final int index;
|
||||
@override@JsonKey(includeIfNull: false) final String? title;
|
||||
@override@JsonKey(includeIfNull: false) final String? language;
|
||||
@override@JsonKey(fromJson: _boolOrFalse) final bool isDefault;
|
||||
@override@JsonKey(fromJson: _boolOrFalse) final bool isForced;
|
||||
@override@JsonKey(fromJson: _boolOrFalse) final bool isExternal;
|
||||
@override@JsonKey(includeIfNull: false) final String? deliveryUrl;
|
||||
@override@JsonKey(includeIfNull: false) final String? codec;
|
||||
|
||||
/// Create a copy of EmbySubtitleTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$EmbySubtitleTrackCopyWith<_EmbySubtitleTrack> get copyWith => __$EmbySubtitleTrackCopyWithImpl<_EmbySubtitleTrack>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$EmbySubtitleTrackToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbySubtitleTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.deliveryUrl, deliveryUrl) || other.deliveryUrl == deliveryUrl)&&(identical(other.codec, codec) || other.codec == codec));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,index,title,language,isDefault,isForced,isExternal,deliveryUrl,codec);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'EmbySubtitleTrack(index: $index, title: $title, language: $language, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, deliveryUrl: $deliveryUrl, codec: $codec)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$EmbySubtitleTrackCopyWith<$Res> implements $EmbySubtitleTrackCopyWith<$Res> {
|
||||
factory _$EmbySubtitleTrackCopyWith(_EmbySubtitleTrack value, $Res Function(_EmbySubtitleTrack) _then) = __$EmbySubtitleTrackCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(fromJson: _boolOrFalse) bool isDefault,@JsonKey(fromJson: _boolOrFalse) bool isForced,@JsonKey(fromJson: _boolOrFalse) bool isExternal,@JsonKey(includeIfNull: false) String? deliveryUrl,@JsonKey(includeIfNull: false) String? codec
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$EmbySubtitleTrackCopyWithImpl<$Res>
|
||||
implements _$EmbySubtitleTrackCopyWith<$Res> {
|
||||
__$EmbySubtitleTrackCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _EmbySubtitleTrack _self;
|
||||
final $Res Function(_EmbySubtitleTrack) _then;
|
||||
|
||||
/// Create a copy of EmbySubtitleTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? deliveryUrl = freezed,Object? codec = freezed,}) {
|
||||
return _then(_EmbySubtitleTrack(
|
||||
index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable
|
||||
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
|
||||
as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable
|
||||
as bool,deliveryUrl: freezed == deliveryUrl ? _self.deliveryUrl : deliveryUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$EmbyAudioTrack {
|
||||
|
||||
@JsonKey(fromJson: _intRequired) int get index;@JsonKey(includeIfNull: false) String? get title;@JsonKey(includeIfNull: false) String? get language;@JsonKey(includeIfNull: false) String? get codec;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get channels;@JsonKey(includeIfNull: false) String? get displayTitle;@JsonKey(fromJson: _boolOrFalse) bool get isDefault;
|
||||
/// Create a copy of EmbyAudioTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$EmbyAudioTrackCopyWith<EmbyAudioTrack> get copyWith => _$EmbyAudioTrackCopyWithImpl<EmbyAudioTrack>(this as EmbyAudioTrack, _$identity);
|
||||
|
||||
/// Serializes this EmbyAudioTrack to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyAudioTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.channels, channels) || other.channels == channels)&&(identical(other.displayTitle, displayTitle) || other.displayTitle == displayTitle)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,index,title,language,codec,channels,displayTitle,isDefault);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'EmbyAudioTrack(index: $index, title: $title, language: $language, codec: $codec, channels: $channels, displayTitle: $displayTitle, isDefault: $isDefault)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $EmbyAudioTrackCopyWith<$Res> {
|
||||
factory $EmbyAudioTrackCopyWith(EmbyAudioTrack value, $Res Function(EmbyAudioTrack) _then) = _$EmbyAudioTrackCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(includeIfNull: false) String? codec,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels,@JsonKey(includeIfNull: false) String? displayTitle,@JsonKey(fromJson: _boolOrFalse) bool isDefault
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$EmbyAudioTrackCopyWithImpl<$Res>
|
||||
implements $EmbyAudioTrackCopyWith<$Res> {
|
||||
_$EmbyAudioTrackCopyWithImpl(this._self, this._then);
|
||||
|
||||
final EmbyAudioTrack _self;
|
||||
final $Res Function(EmbyAudioTrack) _then;
|
||||
|
||||
/// Create a copy of EmbyAudioTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? channels = freezed,Object? displayTitle = freezed,Object? isDefault = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable
|
||||
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
|
||||
as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable
|
||||
as String?,channels: freezed == channels ? _self.channels : channels // ignore: cast_nullable_to_non_nullable
|
||||
as int?,displayTitle: freezed == displayTitle ? _self.displayTitle : displayTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [EmbyAudioTrack].
|
||||
extension EmbyAudioTrackPatterns on EmbyAudioTrack {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _EmbyAudioTrack value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbyAudioTrack() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _EmbyAudioTrack value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbyAudioTrack():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _EmbyAudioTrack value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbyAudioTrack() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(includeIfNull: false) String? codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels, @JsonKey(includeIfNull: false) String? displayTitle, @JsonKey(fromJson: _boolOrFalse) bool isDefault)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbyAudioTrack() when $default != null:
|
||||
return $default(_that.index,_that.title,_that.language,_that.codec,_that.channels,_that.displayTitle,_that.isDefault);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(includeIfNull: false) String? codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels, @JsonKey(includeIfNull: false) String? displayTitle, @JsonKey(fromJson: _boolOrFalse) bool isDefault) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbyAudioTrack():
|
||||
return $default(_that.index,_that.title,_that.language,_that.codec,_that.channels,_that.displayTitle,_that.isDefault);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(includeIfNull: false) String? codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels, @JsonKey(includeIfNull: false) String? displayTitle, @JsonKey(fromJson: _boolOrFalse) bool isDefault)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _EmbyAudioTrack() when $default != null:
|
||||
return $default(_that.index,_that.title,_that.language,_that.codec,_that.channels,_that.displayTitle,_that.isDefault);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _EmbyAudioTrack implements EmbyAudioTrack {
|
||||
const _EmbyAudioTrack({@JsonKey(fromJson: _intRequired) required this.index, @JsonKey(includeIfNull: false) this.title, @JsonKey(includeIfNull: false) this.language, @JsonKey(includeIfNull: false) this.codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.channels, @JsonKey(includeIfNull: false) this.displayTitle, @JsonKey(fromJson: _boolOrFalse) this.isDefault = false});
|
||||
factory _EmbyAudioTrack.fromJson(Map<String, dynamic> json) => _$EmbyAudioTrackFromJson(json);
|
||||
|
||||
@override@JsonKey(fromJson: _intRequired) final int index;
|
||||
@override@JsonKey(includeIfNull: false) final String? title;
|
||||
@override@JsonKey(includeIfNull: false) final String? language;
|
||||
@override@JsonKey(includeIfNull: false) final String? codec;
|
||||
@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? channels;
|
||||
@override@JsonKey(includeIfNull: false) final String? displayTitle;
|
||||
@override@JsonKey(fromJson: _boolOrFalse) final bool isDefault;
|
||||
|
||||
/// Create a copy of EmbyAudioTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$EmbyAudioTrackCopyWith<_EmbyAudioTrack> get copyWith => __$EmbyAudioTrackCopyWithImpl<_EmbyAudioTrack>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$EmbyAudioTrackToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyAudioTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.channels, channels) || other.channels == channels)&&(identical(other.displayTitle, displayTitle) || other.displayTitle == displayTitle)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,index,title,language,codec,channels,displayTitle,isDefault);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'EmbyAudioTrack(index: $index, title: $title, language: $language, codec: $codec, channels: $channels, displayTitle: $displayTitle, isDefault: $isDefault)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$EmbyAudioTrackCopyWith<$Res> implements $EmbyAudioTrackCopyWith<$Res> {
|
||||
factory _$EmbyAudioTrackCopyWith(_EmbyAudioTrack value, $Res Function(_EmbyAudioTrack) _then) = __$EmbyAudioTrackCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(includeIfNull: false) String? codec,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels,@JsonKey(includeIfNull: false) String? displayTitle,@JsonKey(fromJson: _boolOrFalse) bool isDefault
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$EmbyAudioTrackCopyWithImpl<$Res>
|
||||
implements _$EmbyAudioTrackCopyWith<$Res> {
|
||||
__$EmbyAudioTrackCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _EmbyAudioTrack _self;
|
||||
final $Res Function(_EmbyAudioTrack) _then;
|
||||
|
||||
/// Create a copy of EmbyAudioTrack
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? channels = freezed,Object? displayTitle = freezed,Object? isDefault = null,}) {
|
||||
return _then(_EmbyAudioTrack(
|
||||
index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable
|
||||
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
|
||||
as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable
|
||||
as String?,channels: freezed == channels ? _self.channels : channels // ignore: cast_nullable_to_non_nullable
|
||||
as int?,displayTitle: freezed == displayTitle ? _self.displayTitle : displayTitle // ignore: cast_nullable_to_non_nullable
|
||||
as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
Generated
+81
@@ -0,0 +1,81 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'playback.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_PlaybackRequestPayload _$PlaybackRequestPayloadFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _PlaybackRequestPayload(
|
||||
itemId: json['itemId'] as String,
|
||||
itemType: json['itemType'] as String?,
|
||||
mediaSourceId: json['mediaSourceId'] as String?,
|
||||
startPositionTicks: (json['startPositionTicks'] as num?)?.toInt(),
|
||||
playFromStart: json['playFromStart'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlaybackRequestPayloadToJson(
|
||||
_PlaybackRequestPayload instance,
|
||||
) => <String, dynamic>{
|
||||
'itemId': instance.itemId,
|
||||
'itemType': ?instance.itemType,
|
||||
'mediaSourceId': ?instance.mediaSourceId,
|
||||
'startPositionTicks': ?instance.startPositionTicks,
|
||||
'playFromStart': instance.playFromStart,
|
||||
};
|
||||
|
||||
_EmbySubtitleTrack _$EmbySubtitleTrackFromJson(Map<String, dynamic> json) =>
|
||||
_EmbySubtitleTrack(
|
||||
index: _intRequired(json['index']),
|
||||
title: json['title'] as String?,
|
||||
language: json['language'] as String?,
|
||||
isDefault: json['isDefault'] == null
|
||||
? false
|
||||
: _boolOrFalse(json['isDefault']),
|
||||
isForced: json['isForced'] == null
|
||||
? false
|
||||
: _boolOrFalse(json['isForced']),
|
||||
isExternal: json['isExternal'] == null
|
||||
? false
|
||||
: _boolOrFalse(json['isExternal']),
|
||||
deliveryUrl: json['deliveryUrl'] as String?,
|
||||
codec: json['codec'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbySubtitleTrackToJson(_EmbySubtitleTrack instance) =>
|
||||
<String, dynamic>{
|
||||
'index': instance.index,
|
||||
'title': ?instance.title,
|
||||
'language': ?instance.language,
|
||||
'isDefault': instance.isDefault,
|
||||
'isForced': instance.isForced,
|
||||
'isExternal': instance.isExternal,
|
||||
'deliveryUrl': ?instance.deliveryUrl,
|
||||
'codec': ?instance.codec,
|
||||
};
|
||||
|
||||
_EmbyAudioTrack _$EmbyAudioTrackFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyAudioTrack(
|
||||
index: _intRequired(json['index']),
|
||||
title: json['title'] as String?,
|
||||
language: json['language'] as String?,
|
||||
codec: json['codec'] as String?,
|
||||
channels: _intOrNull(json['channels']),
|
||||
displayTitle: json['displayTitle'] as String?,
|
||||
isDefault: json['isDefault'] == null
|
||||
? false
|
||||
: _boolOrFalse(json['isDefault']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyAudioTrackToJson(_EmbyAudioTrack instance) =>
|
||||
<String, dynamic>{
|
||||
'index': instance.index,
|
||||
'title': ?instance.title,
|
||||
'language': ?instance.language,
|
||||
'codec': ?instance.codec,
|
||||
'channels': ?instance.channels,
|
||||
'displayTitle': ?instance.displayTitle,
|
||||
'isDefault': instance.isDefault,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
enum GestureAction {
|
||||
none,
|
||||
playPause,
|
||||
fullscreen,
|
||||
toggleControls,
|
||||
seekBackward,
|
||||
seekForward,
|
||||
previousItem,
|
||||
nextItem,
|
||||
exit,
|
||||
}
|
||||
|
||||
GestureAction gestureActionFromName(String? name, GestureAction fallback) {
|
||||
if (name == null) return fallback;
|
||||
for (final v in GestureAction.values) {
|
||||
if (v.name == name) return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'script_widget.freezed.dart';
|
||||
part 'script_widget.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetOption with _$ScriptWidgetOption {
|
||||
const factory ScriptWidgetOption({
|
||||
@Default('') String title,
|
||||
@JsonKey(fromJson: _stringFromAny) @Default('') String value,
|
||||
}) = _ScriptWidgetOption;
|
||||
|
||||
factory ScriptWidgetOption.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetOptionFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetBelongTo with _$ScriptWidgetBelongTo {
|
||||
const factory ScriptWidgetBelongTo({
|
||||
@Default('') String paramName,
|
||||
@JsonKey(fromJson: _stringListFromAny)
|
||||
@Default(<String>[])
|
||||
List<String> value,
|
||||
}) = _ScriptWidgetBelongTo;
|
||||
|
||||
factory ScriptWidgetBelongTo.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetBelongToFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetParam with _$ScriptWidgetParam {
|
||||
const factory ScriptWidgetParam({
|
||||
required String name,
|
||||
@Default('') String title,
|
||||
@Default('input') String type,
|
||||
@Default('') String description,
|
||||
Object? value,
|
||||
ScriptWidgetBelongTo? belongTo,
|
||||
@Default(<ScriptWidgetOption>[]) List<ScriptWidgetOption> placeholders,
|
||||
@Default(<ScriptWidgetOption>[]) List<ScriptWidgetOption> enumOptions,
|
||||
}) = _ScriptWidgetParam;
|
||||
|
||||
factory ScriptWidgetParam.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetParamFromJson(json);
|
||||
}
|
||||
|
||||
Map<String, Object?> buildScriptWidgetParameterDefaults(
|
||||
List<ScriptWidgetParam> parameters,
|
||||
) {
|
||||
return <String, Object?>{
|
||||
for (final parameter in parameters)
|
||||
parameter.name: _resolveScriptWidgetParameterDefault(parameter),
|
||||
};
|
||||
}
|
||||
|
||||
Object? _resolveScriptWidgetParameterDefault(ScriptWidgetParam parameter) {
|
||||
if (parameter.value != null) return parameter.value;
|
||||
if (parameter.enumOptions.isNotEmpty) {
|
||||
return parameter.enumOptions.first.value;
|
||||
}
|
||||
if (parameter.placeholders.isNotEmpty) {
|
||||
return parameter.placeholders.first.value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetModule with _$ScriptWidgetModule {
|
||||
const factory ScriptWidgetModule({
|
||||
@Default('') String id,
|
||||
required String title,
|
||||
@Default('') String description,
|
||||
@Default(false) bool requiresWebView,
|
||||
required String functionName,
|
||||
@Default(false) bool sectionMode,
|
||||
@Default(3600) int cacheDuration,
|
||||
@Default('list') String type,
|
||||
@Default(<ScriptWidgetParam>[]) List<ScriptWidgetParam> params,
|
||||
}) = _ScriptWidgetModule;
|
||||
|
||||
factory ScriptWidgetModule.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetModuleFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetManifest with _$ScriptWidgetManifest {
|
||||
const factory ScriptWidgetManifest({
|
||||
required String id,
|
||||
required String title,
|
||||
@Default('') String description,
|
||||
@Default('') String author,
|
||||
@Default('') String site,
|
||||
@Default('0.0.0') String version,
|
||||
@Default('0.0.1') String requiredVersion,
|
||||
@Default(60) int detailCacheDuration,
|
||||
@Default(<ScriptWidgetParam>[]) List<ScriptWidgetParam> globalParams,
|
||||
@Default(<ScriptWidgetModule>[]) List<ScriptWidgetModule> modules,
|
||||
ScriptWidgetModule? search,
|
||||
}) = _ScriptWidgetManifest;
|
||||
|
||||
factory ScriptWidgetManifest.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetManifestFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptVideoPerson with _$ScriptVideoPerson {
|
||||
const factory ScriptVideoPerson({
|
||||
@JsonKey(fromJson: _stringFromAny) @Default('') String id,
|
||||
@Default('') String title,
|
||||
@Default('') String avatar,
|
||||
@Default('') String role,
|
||||
}) = _ScriptVideoPerson;
|
||||
|
||||
factory ScriptVideoPerson.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptVideoPersonFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptVideoGenre with _$ScriptVideoGenre {
|
||||
const factory ScriptVideoGenre({
|
||||
@JsonKey(fromJson: _stringFromAny) @Default('') String id,
|
||||
@Default('') String title,
|
||||
}) = _ScriptVideoGenre;
|
||||
|
||||
factory ScriptVideoGenre.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptVideoGenreFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptVideoTrailer with _$ScriptVideoTrailer {
|
||||
const factory ScriptVideoTrailer({
|
||||
@Default('') String coverUrl,
|
||||
@Default('') String url,
|
||||
}) = _ScriptVideoTrailer;
|
||||
|
||||
factory ScriptVideoTrailer.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptVideoTrailerFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptVideoItem with _$ScriptVideoItem {
|
||||
const factory ScriptVideoItem({
|
||||
@JsonKey(fromJson: _stringFromAny) required String id,
|
||||
@Default('link') String type,
|
||||
@Default('') String title,
|
||||
@Default('') String coverUrl,
|
||||
@JsonKey(readValue: _readPosterPath) @Default('') String posterPath,
|
||||
@Default('') String detailPoster,
|
||||
@Default('') String backdropPath,
|
||||
@Default(<String>[]) List<String> backdropPaths,
|
||||
@Default('') String releaseDate,
|
||||
@Default('') String mediaType,
|
||||
@JsonKey(fromJson: _doubleFromAny) double? rating,
|
||||
@Default('') String genreTitle,
|
||||
@Default(<ScriptVideoGenre>[]) List<ScriptVideoGenre> genreItems,
|
||||
@Default(<ScriptVideoPerson>[]) List<ScriptVideoPerson> peoples,
|
||||
@JsonKey(fromJson: _intFromAny) int? duration,
|
||||
@Default('') String durationText,
|
||||
@Default('') String previewUrl,
|
||||
@Default(<ScriptVideoTrailer>[]) List<ScriptVideoTrailer> trailers,
|
||||
@Default('') String videoUrl,
|
||||
@Default('') String link,
|
||||
@JsonKey(fromJson: _intFromAny) int? episode,
|
||||
@Default('') String description,
|
||||
@Default('app') String playerType,
|
||||
@Default(<ScriptVideoItem>[]) List<ScriptVideoItem> childItems,
|
||||
@Default(<ScriptVideoItem>[]) List<ScriptVideoItem> episodeItems,
|
||||
@Default(<ScriptVideoItem>[]) List<ScriptVideoItem> relatedItems,
|
||||
}) = _ScriptVideoItem;
|
||||
|
||||
factory ScriptVideoItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptVideoItemFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptVideoResource with _$ScriptVideoResource {
|
||||
const factory ScriptVideoResource({
|
||||
@Default('') String name,
|
||||
@Default('') String description,
|
||||
required String url,
|
||||
@JsonKey(readValue: _readResourceHeaders)
|
||||
@Default(<String, String>{})
|
||||
Map<String, String> customHeaders,
|
||||
@Default('app') String playerType,
|
||||
}) = _ScriptVideoResource;
|
||||
|
||||
factory ScriptVideoResource.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptVideoResourceFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class InstalledScriptWidget with _$InstalledScriptWidget {
|
||||
const factory InstalledScriptWidget({
|
||||
required ScriptWidgetManifest manifest,
|
||||
required String scriptSource,
|
||||
@Default('') String sourceUrl,
|
||||
@Default(true) bool enabled,
|
||||
@Default(<String, Object?>{}) Map<String, Object?> globalParams,
|
||||
required DateTime installedAt,
|
||||
required DateTime updatedAt,
|
||||
}) = _InstalledScriptWidget;
|
||||
|
||||
factory InstalledScriptWidget.fromJson(Map<String, dynamic> json) =>
|
||||
_$InstalledScriptWidgetFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetCatalogEntry with _$ScriptWidgetCatalogEntry {
|
||||
const factory ScriptWidgetCatalogEntry({
|
||||
required String id,
|
||||
required String title,
|
||||
@Default('') String description,
|
||||
@Default('') String requiredVersion,
|
||||
@Default('') String version,
|
||||
@Default('') String author,
|
||||
required String url,
|
||||
}) = _ScriptWidgetCatalogEntry;
|
||||
|
||||
factory ScriptWidgetCatalogEntry.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetCatalogEntryFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetCatalog with _$ScriptWidgetCatalog {
|
||||
const factory ScriptWidgetCatalog({
|
||||
required String title,
|
||||
@Default('') String description,
|
||||
@Default('') String icon,
|
||||
@Default(<ScriptWidgetCatalogEntry>[])
|
||||
List<ScriptWidgetCatalogEntry> widgets,
|
||||
}) = _ScriptWidgetCatalog;
|
||||
|
||||
factory ScriptWidgetCatalog.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetCatalogFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ScriptWidgetSubscription with _$ScriptWidgetSubscription {
|
||||
const factory ScriptWidgetSubscription({
|
||||
required String url,
|
||||
required ScriptWidgetCatalog catalog,
|
||||
required DateTime refreshedAt,
|
||||
}) = _ScriptWidgetSubscription;
|
||||
|
||||
factory ScriptWidgetSubscription.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScriptWidgetSubscriptionFromJson(json);
|
||||
}
|
||||
|
||||
String _stringFromAny(Object? value) => value?.toString() ?? '';
|
||||
|
||||
int? _intFromAny(Object? value) {
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
double? _doubleFromAny(Object? value) {
|
||||
if (value is num) return value.toDouble();
|
||||
return double.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
List<String> _stringListFromAny(Object? value) {
|
||||
if (value is List) {
|
||||
return value.map((item) => item.toString()).toList(growable: false);
|
||||
}
|
||||
if (value == null) return const <String>[];
|
||||
return <String>[value.toString()];
|
||||
}
|
||||
|
||||
Object? _readPosterPath(Map<dynamic, dynamic> json, String key) {
|
||||
return json[key] ?? json['posterUrl'] ?? json['poster_url'];
|
||||
}
|
||||
|
||||
Object? _readResourceHeaders(Map<dynamic, dynamic> json, String key) {
|
||||
final value = json[key] ?? json['headers'];
|
||||
if (value is! Map) return const <String, String>{};
|
||||
return <String, String>{
|
||||
for (final entry in value.entries)
|
||||
entry.key.toString(): entry.value.toString(),
|
||||
};
|
||||
}
|
||||
+4108
File diff suppressed because it is too large
Load Diff
Generated
+381
@@ -0,0 +1,381 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'script_widget.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_ScriptWidgetOption _$ScriptWidgetOptionFromJson(Map<String, dynamic> json) =>
|
||||
_ScriptWidgetOption(
|
||||
title: json['title'] as String? ?? '',
|
||||
value: json['value'] == null ? '' : _stringFromAny(json['value']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetOptionToJson(_ScriptWidgetOption instance) =>
|
||||
<String, dynamic>{'title': instance.title, 'value': instance.value};
|
||||
|
||||
_ScriptWidgetBelongTo _$ScriptWidgetBelongToFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ScriptWidgetBelongTo(
|
||||
paramName: json['paramName'] as String? ?? '',
|
||||
value: json['value'] == null
|
||||
? const <String>[]
|
||||
: _stringListFromAny(json['value']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetBelongToToJson(
|
||||
_ScriptWidgetBelongTo instance,
|
||||
) => <String, dynamic>{
|
||||
'paramName': instance.paramName,
|
||||
'value': instance.value,
|
||||
};
|
||||
|
||||
_ScriptWidgetParam _$ScriptWidgetParamFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ScriptWidgetParam(
|
||||
name: json['name'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'input',
|
||||
description: json['description'] as String? ?? '',
|
||||
value: json['value'],
|
||||
belongTo: json['belongTo'] == null
|
||||
? null
|
||||
: ScriptWidgetBelongTo.fromJson(json['belongTo'] as Map<String, dynamic>),
|
||||
placeholders:
|
||||
(json['placeholders'] as List<dynamic>?)
|
||||
?.map((e) => ScriptWidgetOption.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptWidgetOption>[],
|
||||
enumOptions:
|
||||
(json['enumOptions'] as List<dynamic>?)
|
||||
?.map((e) => ScriptWidgetOption.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptWidgetOption>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetParamToJson(_ScriptWidgetParam instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
'description': instance.description,
|
||||
'value': instance.value,
|
||||
'belongTo': instance.belongTo,
|
||||
'placeholders': instance.placeholders,
|
||||
'enumOptions': instance.enumOptions,
|
||||
};
|
||||
|
||||
_ScriptWidgetModule _$ScriptWidgetModuleFromJson(Map<String, dynamic> json) =>
|
||||
_ScriptWidgetModule(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
requiresWebView: json['requiresWebView'] as bool? ?? false,
|
||||
functionName: json['functionName'] as String,
|
||||
sectionMode: json['sectionMode'] as bool? ?? false,
|
||||
cacheDuration: (json['cacheDuration'] as num?)?.toInt() ?? 3600,
|
||||
type: json['type'] as String? ?? 'list',
|
||||
params:
|
||||
(json['params'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => ScriptWidgetParam.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
const <ScriptWidgetParam>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetModuleToJson(_ScriptWidgetModule instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'requiresWebView': instance.requiresWebView,
|
||||
'functionName': instance.functionName,
|
||||
'sectionMode': instance.sectionMode,
|
||||
'cacheDuration': instance.cacheDuration,
|
||||
'type': instance.type,
|
||||
'params': instance.params,
|
||||
};
|
||||
|
||||
_ScriptWidgetManifest _$ScriptWidgetManifestFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ScriptWidgetManifest(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
author: json['author'] as String? ?? '',
|
||||
site: json['site'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
requiredVersion: json['requiredVersion'] as String? ?? '0.0.1',
|
||||
detailCacheDuration: (json['detailCacheDuration'] as num?)?.toInt() ?? 60,
|
||||
globalParams:
|
||||
(json['globalParams'] as List<dynamic>?)
|
||||
?.map((e) => ScriptWidgetParam.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptWidgetParam>[],
|
||||
modules:
|
||||
(json['modules'] as List<dynamic>?)
|
||||
?.map((e) => ScriptWidgetModule.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptWidgetModule>[],
|
||||
search: json['search'] == null
|
||||
? null
|
||||
: ScriptWidgetModule.fromJson(json['search'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetManifestToJson(
|
||||
_ScriptWidgetManifest instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'author': instance.author,
|
||||
'site': instance.site,
|
||||
'version': instance.version,
|
||||
'requiredVersion': instance.requiredVersion,
|
||||
'detailCacheDuration': instance.detailCacheDuration,
|
||||
'globalParams': instance.globalParams,
|
||||
'modules': instance.modules,
|
||||
'search': instance.search,
|
||||
};
|
||||
|
||||
_ScriptVideoPerson _$ScriptVideoPersonFromJson(Map<String, dynamic> json) =>
|
||||
_ScriptVideoPerson(
|
||||
id: json['id'] == null ? '' : _stringFromAny(json['id']),
|
||||
title: json['title'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
role: json['role'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptVideoPersonToJson(_ScriptVideoPerson instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'avatar': instance.avatar,
|
||||
'role': instance.role,
|
||||
};
|
||||
|
||||
_ScriptVideoGenre _$ScriptVideoGenreFromJson(Map<String, dynamic> json) =>
|
||||
_ScriptVideoGenre(
|
||||
id: json['id'] == null ? '' : _stringFromAny(json['id']),
|
||||
title: json['title'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptVideoGenreToJson(_ScriptVideoGenre instance) =>
|
||||
<String, dynamic>{'id': instance.id, 'title': instance.title};
|
||||
|
||||
_ScriptVideoTrailer _$ScriptVideoTrailerFromJson(Map<String, dynamic> json) =>
|
||||
_ScriptVideoTrailer(
|
||||
coverUrl: json['coverUrl'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptVideoTrailerToJson(_ScriptVideoTrailer instance) =>
|
||||
<String, dynamic>{'coverUrl': instance.coverUrl, 'url': instance.url};
|
||||
|
||||
_ScriptVideoItem _$ScriptVideoItemFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ScriptVideoItem(
|
||||
id: _stringFromAny(json['id']),
|
||||
type: json['type'] as String? ?? 'link',
|
||||
title: json['title'] as String? ?? '',
|
||||
coverUrl: json['coverUrl'] as String? ?? '',
|
||||
posterPath: _readPosterPath(json, 'posterPath') as String? ?? '',
|
||||
detailPoster: json['detailPoster'] as String? ?? '',
|
||||
backdropPath: json['backdropPath'] as String? ?? '',
|
||||
backdropPaths:
|
||||
(json['backdropPaths'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const <String>[],
|
||||
releaseDate: json['releaseDate'] as String? ?? '',
|
||||
mediaType: json['mediaType'] as String? ?? '',
|
||||
rating: _doubleFromAny(json['rating']),
|
||||
genreTitle: json['genreTitle'] as String? ?? '',
|
||||
genreItems:
|
||||
(json['genreItems'] as List<dynamic>?)
|
||||
?.map((e) => ScriptVideoGenre.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptVideoGenre>[],
|
||||
peoples:
|
||||
(json['peoples'] as List<dynamic>?)
|
||||
?.map((e) => ScriptVideoPerson.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptVideoPerson>[],
|
||||
duration: _intFromAny(json['duration']),
|
||||
durationText: json['durationText'] as String? ?? '',
|
||||
previewUrl: json['previewUrl'] as String? ?? '',
|
||||
trailers:
|
||||
(json['trailers'] as List<dynamic>?)
|
||||
?.map((e) => ScriptVideoTrailer.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptVideoTrailer>[],
|
||||
videoUrl: json['videoUrl'] as String? ?? '',
|
||||
link: json['link'] as String? ?? '',
|
||||
episode: _intFromAny(json['episode']),
|
||||
description: json['description'] as String? ?? '',
|
||||
playerType: json['playerType'] as String? ?? 'app',
|
||||
childItems:
|
||||
(json['childItems'] as List<dynamic>?)
|
||||
?.map((e) => ScriptVideoItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptVideoItem>[],
|
||||
episodeItems:
|
||||
(json['episodeItems'] as List<dynamic>?)
|
||||
?.map((e) => ScriptVideoItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptVideoItem>[],
|
||||
relatedItems:
|
||||
(json['relatedItems'] as List<dynamic>?)
|
||||
?.map((e) => ScriptVideoItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <ScriptVideoItem>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptVideoItemToJson(_ScriptVideoItem instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'coverUrl': instance.coverUrl,
|
||||
'posterPath': instance.posterPath,
|
||||
'detailPoster': instance.detailPoster,
|
||||
'backdropPath': instance.backdropPath,
|
||||
'backdropPaths': instance.backdropPaths,
|
||||
'releaseDate': instance.releaseDate,
|
||||
'mediaType': instance.mediaType,
|
||||
'rating': instance.rating,
|
||||
'genreTitle': instance.genreTitle,
|
||||
'genreItems': instance.genreItems,
|
||||
'peoples': instance.peoples,
|
||||
'duration': instance.duration,
|
||||
'durationText': instance.durationText,
|
||||
'previewUrl': instance.previewUrl,
|
||||
'trailers': instance.trailers,
|
||||
'videoUrl': instance.videoUrl,
|
||||
'link': instance.link,
|
||||
'episode': instance.episode,
|
||||
'description': instance.description,
|
||||
'playerType': instance.playerType,
|
||||
'childItems': instance.childItems,
|
||||
'episodeItems': instance.episodeItems,
|
||||
'relatedItems': instance.relatedItems,
|
||||
};
|
||||
|
||||
_ScriptVideoResource _$ScriptVideoResourceFromJson(Map<String, dynamic> json) =>
|
||||
_ScriptVideoResource(
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
url: json['url'] as String,
|
||||
customHeaders:
|
||||
(_readResourceHeaders(json, 'customHeaders') as Map<String, dynamic>?)
|
||||
?.map((k, e) => MapEntry(k, e as String)) ??
|
||||
const <String, String>{},
|
||||
playerType: json['playerType'] as String? ?? 'app',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptVideoResourceToJson(
|
||||
_ScriptVideoResource instance,
|
||||
) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'url': instance.url,
|
||||
'customHeaders': instance.customHeaders,
|
||||
'playerType': instance.playerType,
|
||||
};
|
||||
|
||||
_InstalledScriptWidget _$InstalledScriptWidgetFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _InstalledScriptWidget(
|
||||
manifest: ScriptWidgetManifest.fromJson(
|
||||
json['manifest'] as Map<String, dynamic>,
|
||||
),
|
||||
scriptSource: json['scriptSource'] as String,
|
||||
sourceUrl: json['sourceUrl'] as String? ?? '',
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
globalParams:
|
||||
json['globalParams'] as Map<String, dynamic>? ??
|
||||
const <String, Object?>{},
|
||||
installedAt: DateTime.parse(json['installedAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InstalledScriptWidgetToJson(
|
||||
_InstalledScriptWidget instance,
|
||||
) => <String, dynamic>{
|
||||
'manifest': instance.manifest,
|
||||
'scriptSource': instance.scriptSource,
|
||||
'sourceUrl': instance.sourceUrl,
|
||||
'enabled': instance.enabled,
|
||||
'globalParams': instance.globalParams,
|
||||
'installedAt': instance.installedAt.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
_ScriptWidgetCatalogEntry _$ScriptWidgetCatalogEntryFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ScriptWidgetCatalogEntry(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
requiredVersion: json['requiredVersion'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '',
|
||||
author: json['author'] as String? ?? '',
|
||||
url: json['url'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetCatalogEntryToJson(
|
||||
_ScriptWidgetCatalogEntry instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'requiredVersion': instance.requiredVersion,
|
||||
'version': instance.version,
|
||||
'author': instance.author,
|
||||
'url': instance.url,
|
||||
};
|
||||
|
||||
_ScriptWidgetCatalog _$ScriptWidgetCatalogFromJson(Map<String, dynamic> json) =>
|
||||
_ScriptWidgetCatalog(
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
icon: json['icon'] as String? ?? '',
|
||||
widgets:
|
||||
(json['widgets'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => ScriptWidgetCatalogEntry.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
const <ScriptWidgetCatalogEntry>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetCatalogToJson(
|
||||
_ScriptWidgetCatalog instance,
|
||||
) => <String, dynamic>{
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'icon': instance.icon,
|
||||
'widgets': instance.widgets,
|
||||
};
|
||||
|
||||
_ScriptWidgetSubscription _$ScriptWidgetSubscriptionFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ScriptWidgetSubscription(
|
||||
url: json['url'] as String,
|
||||
catalog: ScriptWidgetCatalog.fromJson(
|
||||
json['catalog'] as Map<String, dynamic>,
|
||||
),
|
||||
refreshedAt: DateTime.parse(json['refreshedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptWidgetSubscriptionToJson(
|
||||
_ScriptWidgetSubscription instance,
|
||||
) => <String, dynamic>{
|
||||
'url': instance.url,
|
||||
'catalog': instance.catalog,
|
||||
'refreshedAt': instance.refreshedAt.toIso8601String(),
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'server.freezed.dart';
|
||||
part 'server.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class EmbyServer with _$EmbyServer {
|
||||
const factory EmbyServer({
|
||||
required String id,
|
||||
required String name,
|
||||
required String baseUrl,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
@JsonKey(includeIfNull: false) String? lastConnectedAt,
|
||||
|
||||
|
||||
@JsonKey(
|
||||
fromJson: _stringOrEmpty,
|
||||
toJson: _emptyStringToNull,
|
||||
includeIfNull: false,
|
||||
)
|
||||
@Default('')
|
||||
String iconUrl,
|
||||
|
||||
|
||||
@Default(false) bool paused,
|
||||
}) = _EmbyServer;
|
||||
|
||||
factory EmbyServer.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyServerFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyServerSaveReq with _$EmbyServerSaveReq {
|
||||
const factory EmbyServerSaveReq({
|
||||
@JsonKey(includeIfNull: false) String? id,
|
||||
required String name,
|
||||
required String baseUrl,
|
||||
|
||||
|
||||
@JsonKey(
|
||||
fromJson: _stringOrEmpty,
|
||||
toJson: _emptyStringToNull,
|
||||
includeIfNull: false,
|
||||
)
|
||||
@Default('')
|
||||
String iconUrl,
|
||||
}) = _EmbyServerSaveReq;
|
||||
|
||||
factory EmbyServerSaveReq.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyServerSaveReqFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class EmbyServerProbeReq with _$EmbyServerProbeReq {
|
||||
const factory EmbyServerProbeReq({required String baseUrl}) =
|
||||
_EmbyServerProbeReq;
|
||||
|
||||
factory EmbyServerProbeReq.fromJson(Map<String, dynamic> json) =>
|
||||
_$EmbyServerProbeReqFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ProbePublicInfoRes with _$ProbePublicInfoRes {
|
||||
const factory ProbePublicInfoRes({
|
||||
required String serverName,
|
||||
required String version,
|
||||
required String productName,
|
||||
}) = _ProbePublicInfoRes;
|
||||
|
||||
factory ProbePublicInfoRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProbePublicInfoResFromJson(json);
|
||||
}
|
||||
|
||||
String _stringOrEmpty(Object? value) => value?.toString() ?? '';
|
||||
|
||||
String? _emptyStringToNull(String value) => value.isEmpty ? null : value;
|
||||
Generated
+1112
File diff suppressed because it is too large
Load Diff
Generated
+66
@@ -0,0 +1,66 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'server.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_EmbyServer _$EmbyServerFromJson(Map<String, dynamic> json) => _EmbyServer(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
baseUrl: json['baseUrl'] as String,
|
||||
createdAt: json['createdAt'] as String,
|
||||
updatedAt: json['updatedAt'] as String,
|
||||
lastConnectedAt: json['lastConnectedAt'] as String?,
|
||||
iconUrl: json['iconUrl'] == null ? '' : _stringOrEmpty(json['iconUrl']),
|
||||
paused: json['paused'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyServerToJson(_EmbyServer instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'baseUrl': instance.baseUrl,
|
||||
'createdAt': instance.createdAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastConnectedAt': ?instance.lastConnectedAt,
|
||||
'iconUrl': ?_emptyStringToNull(instance.iconUrl),
|
||||
'paused': instance.paused,
|
||||
};
|
||||
|
||||
_EmbyServerSaveReq _$EmbyServerSaveReqFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyServerSaveReq(
|
||||
id: json['id'] as String?,
|
||||
name: json['name'] as String,
|
||||
baseUrl: json['baseUrl'] as String,
|
||||
iconUrl: json['iconUrl'] == null ? '' : _stringOrEmpty(json['iconUrl']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmbyServerSaveReqToJson(_EmbyServerSaveReq instance) =>
|
||||
<String, dynamic>{
|
||||
'id': ?instance.id,
|
||||
'name': instance.name,
|
||||
'baseUrl': instance.baseUrl,
|
||||
'iconUrl': ?_emptyStringToNull(instance.iconUrl),
|
||||
};
|
||||
|
||||
_EmbyServerProbeReq _$EmbyServerProbeReqFromJson(Map<String, dynamic> json) =>
|
||||
_EmbyServerProbeReq(baseUrl: json['baseUrl'] as String);
|
||||
|
||||
Map<String, dynamic> _$EmbyServerProbeReqToJson(_EmbyServerProbeReq instance) =>
|
||||
<String, dynamic>{'baseUrl': instance.baseUrl};
|
||||
|
||||
_ProbePublicInfoRes _$ProbePublicInfoResFromJson(Map<String, dynamic> json) =>
|
||||
_ProbePublicInfoRes(
|
||||
serverName: json['serverName'] as String,
|
||||
version: json['version'] as String,
|
||||
productName: json['productName'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ProbePublicInfoResToJson(_ProbePublicInfoRes instance) =>
|
||||
<String, dynamic>{
|
||||
'serverName': instance.serverName,
|
||||
'version': instance.version,
|
||||
'productName': instance.productName,
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'tmdb.freezed.dart';
|
||||
part 'tmdb.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class TmdbCastMember with _$TmdbCastMember {
|
||||
const factory TmdbCastMember({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
String? character,
|
||||
@JsonKey(name: 'profile_path') String? profilePath,
|
||||
@JsonKey(fromJson: _intOrZero) @Default(0) int order,
|
||||
}) = _TmdbCastMember;
|
||||
|
||||
factory TmdbCastMember.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbCastMemberFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbCrewMember with _$TmdbCrewMember {
|
||||
const factory TmdbCrewMember({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
String? job,
|
||||
String? department,
|
||||
@JsonKey(name: 'profile_path') String? profilePath,
|
||||
}) = _TmdbCrewMember;
|
||||
|
||||
factory TmdbCrewMember.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbCrewMemberFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbCreditsRes with _$TmdbCreditsRes {
|
||||
const factory TmdbCreditsRes({
|
||||
@Default(<TmdbCastMember>[]) List<TmdbCastMember> cast,
|
||||
@Default(<TmdbCrewMember>[]) List<TmdbCrewMember> crew,
|
||||
}) = _TmdbCreditsRes;
|
||||
|
||||
factory TmdbCreditsRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbCreditsResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbRecommendation with _$TmdbRecommendation {
|
||||
const factory TmdbRecommendation({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
required String title,
|
||||
@JsonKey(name: 'poster_path') String? posterPath,
|
||||
@JsonKey(name: 'backdrop_path') String? backdropPath,
|
||||
String? overview,
|
||||
@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,
|
||||
@JsonKey(name: 'media_type') String? mediaType,
|
||||
}) = _TmdbRecommendation;
|
||||
|
||||
factory TmdbRecommendation.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbRecommendationFromJson({
|
||||
...json,
|
||||
'title': json['title'] ?? json['name'] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbRecommendationsRes with _$TmdbRecommendationsRes {
|
||||
const factory TmdbRecommendationsRes({
|
||||
@Default(<TmdbRecommendation>[]) List<TmdbRecommendation> results,
|
||||
@JsonKey(name: 'total_results', fromJson: _intOrZero)
|
||||
@Default(0)
|
||||
int totalResults,
|
||||
}) = _TmdbRecommendationsRes;
|
||||
|
||||
factory TmdbRecommendationsRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbRecommendationsResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbEpisode with _$TmdbEpisode {
|
||||
const factory TmdbEpisode({
|
||||
@JsonKey(name: 'episode_number', fromJson: _intRequired)
|
||||
required int episodeNumber,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
String? overview,
|
||||
@JsonKey(name: 'still_path') String? stillPath,
|
||||
@JsonKey(name: 'air_date') String? airDate,
|
||||
@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,
|
||||
@JsonKey(fromJson: _intOrNull) int? runtime,
|
||||
}) = _TmdbEpisode;
|
||||
|
||||
factory TmdbEpisode.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbEpisodeFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbSeasonDetail with _$TmdbSeasonDetail {
|
||||
const factory TmdbSeasonDetail({
|
||||
@JsonKey(name: 'season_number', fromJson: _intRequired)
|
||||
required int seasonNumber,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
String? overview,
|
||||
@JsonKey(name: 'poster_path') String? posterPath,
|
||||
@Default(<TmdbEpisode>[]) List<TmdbEpisode> episodes,
|
||||
}) = _TmdbSeasonDetail;
|
||||
|
||||
factory TmdbSeasonDetail.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbSeasonDetailFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbVideo with _$TmdbVideo {
|
||||
const TmdbVideo._();
|
||||
|
||||
const factory TmdbVideo({
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String id,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String key,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String site,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String type,
|
||||
@JsonKey(fromJson: _intOrZero) @Default(0) int size,
|
||||
@JsonKey(fromJson: _boolOrFalse) @Default(false) bool official,
|
||||
}) = _TmdbVideo;
|
||||
|
||||
factory TmdbVideo.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbVideoFromJson(json);
|
||||
|
||||
String? get youtubeUrl => site == 'YouTube' && key.isNotEmpty
|
||||
? 'https://www.youtube.com/watch?v=$key'
|
||||
: null;
|
||||
|
||||
String? get youtubeThumbnailUrl => site == 'YouTube' && key.isNotEmpty
|
||||
? 'https://img.youtube.com/vi/$key/mqdefault.jpg'
|
||||
: null;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbVideosRes with _$TmdbVideosRes {
|
||||
const factory TmdbVideosRes({
|
||||
@Default(<TmdbVideo>[]) List<TmdbVideo> results,
|
||||
}) = _TmdbVideosRes;
|
||||
|
||||
factory TmdbVideosRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbVideosResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbPersonDetail with _$TmdbPersonDetail {
|
||||
const factory TmdbPersonDetail({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
String? biography,
|
||||
String? birthday,
|
||||
String? deathday,
|
||||
@JsonKey(name: 'place_of_birth') String? placeOfBirth,
|
||||
@JsonKey(name: 'profile_path') String? profilePath,
|
||||
@JsonKey(name: 'known_for_department') String? knownForDepartment,
|
||||
@JsonKey(fromJson: _doubleOrNull) double? popularity,
|
||||
}) = _TmdbPersonDetail;
|
||||
|
||||
factory TmdbPersonDetail.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbPersonDetailFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbPersonCredit with _$TmdbPersonCredit {
|
||||
const factory TmdbPersonCredit({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
required String title,
|
||||
String? character,
|
||||
@JsonKey(name: 'poster_path') String? posterPath,
|
||||
@JsonKey(name: 'release_date') String? releaseDate,
|
||||
@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,
|
||||
@JsonKey(name: 'media_type') String? mediaType,
|
||||
}) = _TmdbPersonCredit;
|
||||
|
||||
factory TmdbPersonCredit.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbPersonCreditFromJson({
|
||||
...json,
|
||||
'title': json['title'] ?? json['name'] ?? '',
|
||||
'release_date': json['release_date'] ?? json['first_air_date'],
|
||||
});
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbPersonCreditsRes with _$TmdbPersonCreditsRes {
|
||||
const factory TmdbPersonCreditsRes({
|
||||
@Default(<TmdbPersonCredit>[]) List<TmdbPersonCredit> cast,
|
||||
}) = _TmdbPersonCreditsRes;
|
||||
|
||||
factory TmdbPersonCreditsRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbPersonCreditsResFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbGenre with _$TmdbGenre {
|
||||
const factory TmdbGenre({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
}) = _TmdbGenre;
|
||||
|
||||
factory TmdbGenre.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbGenreFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbMediaDetail with _$TmdbMediaDetail {
|
||||
const factory TmdbMediaDetail({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String title,
|
||||
@JsonKey(name: 'poster_path') String? posterPath,
|
||||
@JsonKey(name: 'backdrop_path') String? backdropPath,
|
||||
@JsonKey(name: 'release_date') String? releaseDate,
|
||||
@Default(<TmdbGenre>[]) List<TmdbGenre> genres,
|
||||
@JsonKey(fromJson: _intOrNull) int? runtime,
|
||||
String? tagline,
|
||||
String? overview,
|
||||
String? status,
|
||||
String? homepage,
|
||||
@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,
|
||||
@JsonKey(name: 'vote_count', fromJson: _intOrNull) int? voteCount,
|
||||
@JsonKey(fromJson: _intOrNull) int? revenue,
|
||||
@JsonKey(fromJson: _intOrNull) int? budget,
|
||||
}) = _TmdbMediaDetail;
|
||||
|
||||
|
||||
factory TmdbMediaDetail.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbMediaDetailFromJson({
|
||||
...json,
|
||||
'title': json['title'] ?? json['name'] ?? '',
|
||||
'release_date': json['release_date'] ?? json['first_air_date'],
|
||||
'runtime':
|
||||
json['runtime'] ??
|
||||
(json['episode_run_time'] is List &&
|
||||
(json['episode_run_time'] as List).isNotEmpty
|
||||
? (json['episode_run_time'] as List).first
|
||||
: null),
|
||||
});
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbImage with _$TmdbImage {
|
||||
const TmdbImage._();
|
||||
|
||||
const factory TmdbImage({
|
||||
@JsonKey(name: 'file_path', fromJson: _strOrEmpty)
|
||||
@Default('')
|
||||
String filePath,
|
||||
@JsonKey(fromJson: _intOrZero) @Default(0) int width,
|
||||
@JsonKey(fromJson: _intOrZero) @Default(0) int height,
|
||||
@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,
|
||||
@JsonKey(name: 'iso_639_1') String? iso639,
|
||||
}) = _TmdbImage;
|
||||
|
||||
factory TmdbImage.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbImageFromJson(json);
|
||||
|
||||
bool get isLanguageNeutral => iso639 == null;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbImageSet with _$TmdbImageSet {
|
||||
const factory TmdbImageSet({
|
||||
@Default(<TmdbImage>[]) List<TmdbImage> backdrops,
|
||||
@Default(<TmdbImage>[]) List<TmdbImage> posters,
|
||||
@Default(<TmdbImage>[]) List<TmdbImage> logos,
|
||||
}) = _TmdbImageSet;
|
||||
|
||||
factory TmdbImageSet.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbImageSetFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbNetwork with _$TmdbNetwork {
|
||||
const factory TmdbNetwork({
|
||||
@JsonKey(fromJson: _intRequired) required int id,
|
||||
@JsonKey(fromJson: _strOrEmpty) @Default('') String name,
|
||||
@JsonKey(name: 'logo_path') String? logoPath,
|
||||
@JsonKey(name: 'origin_country') String? originCountry,
|
||||
}) = _TmdbNetwork;
|
||||
|
||||
factory TmdbNetwork.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbNetworkFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TmdbEnrichedDetail with _$TmdbEnrichedDetail {
|
||||
const factory TmdbEnrichedDetail({
|
||||
required TmdbMediaDetail detail,
|
||||
TmdbVideosRes? videos,
|
||||
TmdbCreditsRes? credits,
|
||||
TmdbRecommendationsRes? recommendations,
|
||||
TmdbImageSet? images,
|
||||
|
||||
|
||||
String? imdbId,
|
||||
@Default(<TmdbNetwork>[]) List<TmdbNetwork> networks,
|
||||
}) = _TmdbEnrichedDetail;
|
||||
|
||||
factory TmdbEnrichedDetail.fromJson(Map<String, dynamic> json) =>
|
||||
_$TmdbEnrichedDetailFromJson({
|
||||
'detail': json,
|
||||
'videos': json['videos'],
|
||||
'credits': json['credits'],
|
||||
'recommendations': json['recommendations'],
|
||||
'images': json['images'],
|
||||
'imdbId': _imdbIdOf(json['external_ids']),
|
||||
'networks': json['networks'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
String? _imdbIdOf(Object? externalIds) {
|
||||
if (externalIds is! Map) return null;
|
||||
final v = externalIds['imdb_id'];
|
||||
if (v is! String) return null;
|
||||
final trimmed = v.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
int _intRequired(Object? v) => (v as num).toInt();
|
||||
int _intOrZero(Object? v) => (v as num?)?.toInt() ?? 0;
|
||||
int? _intOrNull(Object? v) => (v as num?)?.toInt();
|
||||
double? _doubleOrNull(Object? v) => (v as num?)?.toDouble();
|
||||
bool _boolOrFalse(Object? v) => (v as bool?) ?? false;
|
||||
String _strOrEmpty(Object? v) => v?.toString() ?? '';
|
||||
Generated
+5162
File diff suppressed because it is too large
Load Diff
Generated
+384
@@ -0,0 +1,384 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'tmdb.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_TmdbCastMember _$TmdbCastMemberFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbCastMember(
|
||||
id: _intRequired(json['id']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
character: json['character'] as String?,
|
||||
profilePath: json['profile_path'] as String?,
|
||||
order: json['order'] == null ? 0 : _intOrZero(json['order']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbCastMemberToJson(_TmdbCastMember instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'character': instance.character,
|
||||
'profile_path': instance.profilePath,
|
||||
'order': instance.order,
|
||||
};
|
||||
|
||||
_TmdbCrewMember _$TmdbCrewMemberFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbCrewMember(
|
||||
id: _intRequired(json['id']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
job: json['job'] as String?,
|
||||
department: json['department'] as String?,
|
||||
profilePath: json['profile_path'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbCrewMemberToJson(_TmdbCrewMember instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'job': instance.job,
|
||||
'department': instance.department,
|
||||
'profile_path': instance.profilePath,
|
||||
};
|
||||
|
||||
_TmdbCreditsRes _$TmdbCreditsResFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbCreditsRes(
|
||||
cast:
|
||||
(json['cast'] as List<dynamic>?)
|
||||
?.map((e) => TmdbCastMember.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbCastMember>[],
|
||||
crew:
|
||||
(json['crew'] as List<dynamic>?)
|
||||
?.map((e) => TmdbCrewMember.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbCrewMember>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbCreditsResToJson(_TmdbCreditsRes instance) =>
|
||||
<String, dynamic>{'cast': instance.cast, 'crew': instance.crew};
|
||||
|
||||
_TmdbRecommendation _$TmdbRecommendationFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbRecommendation(
|
||||
id: _intRequired(json['id']),
|
||||
title: json['title'] as String,
|
||||
posterPath: json['poster_path'] as String?,
|
||||
backdropPath: json['backdrop_path'] as String?,
|
||||
overview: json['overview'] as String?,
|
||||
voteAverage: _doubleOrNull(json['vote_average']),
|
||||
mediaType: json['media_type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbRecommendationToJson(_TmdbRecommendation instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'poster_path': instance.posterPath,
|
||||
'backdrop_path': instance.backdropPath,
|
||||
'overview': instance.overview,
|
||||
'vote_average': instance.voteAverage,
|
||||
'media_type': instance.mediaType,
|
||||
};
|
||||
|
||||
_TmdbRecommendationsRes _$TmdbRecommendationsResFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _TmdbRecommendationsRes(
|
||||
results:
|
||||
(json['results'] as List<dynamic>?)
|
||||
?.map((e) => TmdbRecommendation.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbRecommendation>[],
|
||||
totalResults: json['total_results'] == null
|
||||
? 0
|
||||
: _intOrZero(json['total_results']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbRecommendationsResToJson(
|
||||
_TmdbRecommendationsRes instance,
|
||||
) => <String, dynamic>{
|
||||
'results': instance.results,
|
||||
'total_results': instance.totalResults,
|
||||
};
|
||||
|
||||
_TmdbEpisode _$TmdbEpisodeFromJson(Map<String, dynamic> json) => _TmdbEpisode(
|
||||
episodeNumber: _intRequired(json['episode_number']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
overview: json['overview'] as String?,
|
||||
stillPath: json['still_path'] as String?,
|
||||
airDate: json['air_date'] as String?,
|
||||
voteAverage: _doubleOrNull(json['vote_average']),
|
||||
runtime: _intOrNull(json['runtime']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbEpisodeToJson(_TmdbEpisode instance) =>
|
||||
<String, dynamic>{
|
||||
'episode_number': instance.episodeNumber,
|
||||
'name': instance.name,
|
||||
'overview': instance.overview,
|
||||
'still_path': instance.stillPath,
|
||||
'air_date': instance.airDate,
|
||||
'vote_average': instance.voteAverage,
|
||||
'runtime': instance.runtime,
|
||||
};
|
||||
|
||||
_TmdbSeasonDetail _$TmdbSeasonDetailFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbSeasonDetail(
|
||||
seasonNumber: _intRequired(json['season_number']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
overview: json['overview'] as String?,
|
||||
posterPath: json['poster_path'] as String?,
|
||||
episodes:
|
||||
(json['episodes'] as List<dynamic>?)
|
||||
?.map((e) => TmdbEpisode.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbEpisode>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbSeasonDetailToJson(_TmdbSeasonDetail instance) =>
|
||||
<String, dynamic>{
|
||||
'season_number': instance.seasonNumber,
|
||||
'name': instance.name,
|
||||
'overview': instance.overview,
|
||||
'poster_path': instance.posterPath,
|
||||
'episodes': instance.episodes,
|
||||
};
|
||||
|
||||
_TmdbVideo _$TmdbVideoFromJson(Map<String, dynamic> json) => _TmdbVideo(
|
||||
id: json['id'] == null ? '' : _strOrEmpty(json['id']),
|
||||
key: json['key'] == null ? '' : _strOrEmpty(json['key']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
site: json['site'] == null ? '' : _strOrEmpty(json['site']),
|
||||
type: json['type'] == null ? '' : _strOrEmpty(json['type']),
|
||||
size: json['size'] == null ? 0 : _intOrZero(json['size']),
|
||||
official: json['official'] == null ? false : _boolOrFalse(json['official']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbVideoToJson(_TmdbVideo instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'key': instance.key,
|
||||
'name': instance.name,
|
||||
'site': instance.site,
|
||||
'type': instance.type,
|
||||
'size': instance.size,
|
||||
'official': instance.official,
|
||||
};
|
||||
|
||||
_TmdbVideosRes _$TmdbVideosResFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbVideosRes(
|
||||
results:
|
||||
(json['results'] as List<dynamic>?)
|
||||
?.map((e) => TmdbVideo.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbVideo>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbVideosResToJson(_TmdbVideosRes instance) =>
|
||||
<String, dynamic>{'results': instance.results};
|
||||
|
||||
_TmdbPersonDetail _$TmdbPersonDetailFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbPersonDetail(
|
||||
id: _intRequired(json['id']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
biography: json['biography'] as String?,
|
||||
birthday: json['birthday'] as String?,
|
||||
deathday: json['deathday'] as String?,
|
||||
placeOfBirth: json['place_of_birth'] as String?,
|
||||
profilePath: json['profile_path'] as String?,
|
||||
knownForDepartment: json['known_for_department'] as String?,
|
||||
popularity: _doubleOrNull(json['popularity']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbPersonDetailToJson(_TmdbPersonDetail instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'biography': instance.biography,
|
||||
'birthday': instance.birthday,
|
||||
'deathday': instance.deathday,
|
||||
'place_of_birth': instance.placeOfBirth,
|
||||
'profile_path': instance.profilePath,
|
||||
'known_for_department': instance.knownForDepartment,
|
||||
'popularity': instance.popularity,
|
||||
};
|
||||
|
||||
_TmdbPersonCredit _$TmdbPersonCreditFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbPersonCredit(
|
||||
id: _intRequired(json['id']),
|
||||
title: json['title'] as String,
|
||||
character: json['character'] as String?,
|
||||
posterPath: json['poster_path'] as String?,
|
||||
releaseDate: json['release_date'] as String?,
|
||||
voteAverage: _doubleOrNull(json['vote_average']),
|
||||
mediaType: json['media_type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbPersonCreditToJson(_TmdbPersonCredit instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'character': instance.character,
|
||||
'poster_path': instance.posterPath,
|
||||
'release_date': instance.releaseDate,
|
||||
'vote_average': instance.voteAverage,
|
||||
'media_type': instance.mediaType,
|
||||
};
|
||||
|
||||
_TmdbPersonCreditsRes _$TmdbPersonCreditsResFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _TmdbPersonCreditsRes(
|
||||
cast:
|
||||
(json['cast'] as List<dynamic>?)
|
||||
?.map((e) => TmdbPersonCredit.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbPersonCredit>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbPersonCreditsResToJson(
|
||||
_TmdbPersonCreditsRes instance,
|
||||
) => <String, dynamic>{'cast': instance.cast};
|
||||
|
||||
_TmdbGenre _$TmdbGenreFromJson(Map<String, dynamic> json) => _TmdbGenre(
|
||||
id: _intRequired(json['id']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbGenreToJson(_TmdbGenre instance) =>
|
||||
<String, dynamic>{'id': instance.id, 'name': instance.name};
|
||||
|
||||
_TmdbMediaDetail _$TmdbMediaDetailFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbMediaDetail(
|
||||
id: _intRequired(json['id']),
|
||||
title: json['title'] == null ? '' : _strOrEmpty(json['title']),
|
||||
posterPath: json['poster_path'] as String?,
|
||||
backdropPath: json['backdrop_path'] as String?,
|
||||
releaseDate: json['release_date'] as String?,
|
||||
genres:
|
||||
(json['genres'] as List<dynamic>?)
|
||||
?.map((e) => TmdbGenre.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbGenre>[],
|
||||
runtime: _intOrNull(json['runtime']),
|
||||
tagline: json['tagline'] as String?,
|
||||
overview: json['overview'] as String?,
|
||||
status: json['status'] as String?,
|
||||
homepage: json['homepage'] as String?,
|
||||
voteAverage: _doubleOrNull(json['vote_average']),
|
||||
voteCount: _intOrNull(json['vote_count']),
|
||||
revenue: _intOrNull(json['revenue']),
|
||||
budget: _intOrNull(json['budget']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbMediaDetailToJson(_TmdbMediaDetail instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'poster_path': instance.posterPath,
|
||||
'backdrop_path': instance.backdropPath,
|
||||
'release_date': instance.releaseDate,
|
||||
'genres': instance.genres,
|
||||
'runtime': instance.runtime,
|
||||
'tagline': instance.tagline,
|
||||
'overview': instance.overview,
|
||||
'status': instance.status,
|
||||
'homepage': instance.homepage,
|
||||
'vote_average': instance.voteAverage,
|
||||
'vote_count': instance.voteCount,
|
||||
'revenue': instance.revenue,
|
||||
'budget': instance.budget,
|
||||
};
|
||||
|
||||
_TmdbImage _$TmdbImageFromJson(Map<String, dynamic> json) => _TmdbImage(
|
||||
filePath: json['file_path'] == null ? '' : _strOrEmpty(json['file_path']),
|
||||
width: json['width'] == null ? 0 : _intOrZero(json['width']),
|
||||
height: json['height'] == null ? 0 : _intOrZero(json['height']),
|
||||
voteAverage: _doubleOrNull(json['vote_average']),
|
||||
iso639: json['iso_639_1'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbImageToJson(_TmdbImage instance) =>
|
||||
<String, dynamic>{
|
||||
'file_path': instance.filePath,
|
||||
'width': instance.width,
|
||||
'height': instance.height,
|
||||
'vote_average': instance.voteAverage,
|
||||
'iso_639_1': instance.iso639,
|
||||
};
|
||||
|
||||
_TmdbImageSet _$TmdbImageSetFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbImageSet(
|
||||
backdrops:
|
||||
(json['backdrops'] as List<dynamic>?)
|
||||
?.map((e) => TmdbImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbImage>[],
|
||||
posters:
|
||||
(json['posters'] as List<dynamic>?)
|
||||
?.map((e) => TmdbImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbImage>[],
|
||||
logos:
|
||||
(json['logos'] as List<dynamic>?)
|
||||
?.map((e) => TmdbImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbImage>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbImageSetToJson(_TmdbImageSet instance) =>
|
||||
<String, dynamic>{
|
||||
'backdrops': instance.backdrops,
|
||||
'posters': instance.posters,
|
||||
'logos': instance.logos,
|
||||
};
|
||||
|
||||
_TmdbNetwork _$TmdbNetworkFromJson(Map<String, dynamic> json) => _TmdbNetwork(
|
||||
id: _intRequired(json['id']),
|
||||
name: json['name'] == null ? '' : _strOrEmpty(json['name']),
|
||||
logoPath: json['logo_path'] as String?,
|
||||
originCountry: json['origin_country'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbNetworkToJson(_TmdbNetwork instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'logo_path': instance.logoPath,
|
||||
'origin_country': instance.originCountry,
|
||||
};
|
||||
|
||||
_TmdbEnrichedDetail _$TmdbEnrichedDetailFromJson(Map<String, dynamic> json) =>
|
||||
_TmdbEnrichedDetail(
|
||||
detail: TmdbMediaDetail.fromJson(json['detail'] as Map<String, dynamic>),
|
||||
videos: json['videos'] == null
|
||||
? null
|
||||
: TmdbVideosRes.fromJson(json['videos'] as Map<String, dynamic>),
|
||||
credits: json['credits'] == null
|
||||
? null
|
||||
: TmdbCreditsRes.fromJson(json['credits'] as Map<String, dynamic>),
|
||||
recommendations: json['recommendations'] == null
|
||||
? null
|
||||
: TmdbRecommendationsRes.fromJson(
|
||||
json['recommendations'] as Map<String, dynamic>,
|
||||
),
|
||||
images: json['images'] == null
|
||||
? null
|
||||
: TmdbImageSet.fromJson(json['images'] as Map<String, dynamic>),
|
||||
imdbId: json['imdbId'] as String?,
|
||||
networks:
|
||||
(json['networks'] as List<dynamic>?)
|
||||
?.map((e) => TmdbNetwork.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <TmdbNetwork>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TmdbEnrichedDetailToJson(_TmdbEnrichedDetail instance) =>
|
||||
<String, dynamic>{
|
||||
'detail': instance.detail,
|
||||
'videos': instance.videos,
|
||||
'credits': instance.credits,
|
||||
'recommendations': instance.recommendations,
|
||||
'images': instance.images,
|
||||
'imdbId': instance.imdbId,
|
||||
'networks': instance.networks,
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'trakt_ids.dart';
|
||||
|
||||
|
||||
class TraktCalendarEntry {
|
||||
|
||||
final DateTime firstAired;
|
||||
|
||||
|
||||
final String type;
|
||||
|
||||
final String? showTitle;
|
||||
final int? showYear;
|
||||
final TraktIds? showIds;
|
||||
final int? episodeSeason;
|
||||
final int? episodeNumber;
|
||||
final String? episodeTitle;
|
||||
final TraktIds? episodeIds;
|
||||
|
||||
final String? movieTitle;
|
||||
final int? movieYear;
|
||||
final TraktIds? movieIds;
|
||||
|
||||
|
||||
final String? posterUrl;
|
||||
|
||||
const TraktCalendarEntry({
|
||||
required this.firstAired,
|
||||
required this.type,
|
||||
this.showTitle,
|
||||
this.showYear,
|
||||
this.showIds,
|
||||
this.episodeSeason,
|
||||
this.episodeNumber,
|
||||
this.episodeTitle,
|
||||
this.episodeIds,
|
||||
this.movieTitle,
|
||||
this.movieYear,
|
||||
this.movieIds,
|
||||
this.posterUrl,
|
||||
});
|
||||
|
||||
TraktCalendarEntry withPoster(String? url) => TraktCalendarEntry(
|
||||
firstAired: firstAired,
|
||||
type: type,
|
||||
showTitle: showTitle,
|
||||
showYear: showYear,
|
||||
showIds: showIds,
|
||||
episodeSeason: episodeSeason,
|
||||
episodeNumber: episodeNumber,
|
||||
episodeTitle: episodeTitle,
|
||||
episodeIds: episodeIds,
|
||||
movieTitle: movieTitle,
|
||||
movieYear: movieYear,
|
||||
movieIds: movieIds,
|
||||
posterUrl: url,
|
||||
);
|
||||
|
||||
TraktCalendarEntry withTitle(String title) => TraktCalendarEntry(
|
||||
firstAired: firstAired,
|
||||
type: type,
|
||||
showTitle: type == 'movie' ? showTitle : title,
|
||||
showYear: showYear,
|
||||
showIds: showIds,
|
||||
episodeSeason: episodeSeason,
|
||||
episodeNumber: episodeNumber,
|
||||
episodeTitle: episodeTitle,
|
||||
episodeIds: episodeIds,
|
||||
movieTitle: type == 'movie' ? title : movieTitle,
|
||||
movieYear: movieYear,
|
||||
movieIds: movieIds,
|
||||
posterUrl: posterUrl,
|
||||
);
|
||||
|
||||
|
||||
int? get tmdbId => type == 'movie' ? movieIds?.tmdb : showIds?.tmdb;
|
||||
|
||||
|
||||
String get mediaType => type == 'movie' ? 'movie' : 'tv';
|
||||
|
||||
|
||||
String get displayTitle =>
|
||||
type == 'movie' ? (movieTitle ?? '') : (showTitle ?? '');
|
||||
|
||||
|
||||
String get displaySubtitle {
|
||||
if (type == 'movie') return movieYear?.toString() ?? '';
|
||||
final parts = <String>[];
|
||||
if (episodeSeason != null && episodeNumber != null) {
|
||||
parts.add(
|
||||
'S${episodeSeason.toString().padLeft(2, '0')}'
|
||||
'E${episodeNumber.toString().padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
if (episodeTitle != null && episodeTitle!.isNotEmpty) {
|
||||
parts.add(episodeTitle!);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
|
||||
static TraktCalendarEntry? fromShowJson(Map<String, dynamic> json) {
|
||||
final aired = _parseDate(json['first_aired']);
|
||||
if (aired == null) return null;
|
||||
|
||||
final show = json['show'];
|
||||
final episode = json['episode'];
|
||||
if (show is! Map<String, dynamic>) return null;
|
||||
|
||||
return TraktCalendarEntry(
|
||||
firstAired: aired,
|
||||
type: 'show',
|
||||
showTitle: show['title'] as String?,
|
||||
showYear: (show['year'] as num?)?.toInt(),
|
||||
showIds: TraktIds.fromJson(show['ids']),
|
||||
episodeSeason: episode is Map<String, dynamic>
|
||||
? (episode['season'] as num?)?.toInt()
|
||||
: null,
|
||||
episodeNumber: episode is Map<String, dynamic>
|
||||
? (episode['number'] as num?)?.toInt()
|
||||
: null,
|
||||
episodeTitle: episode is Map<String, dynamic>
|
||||
? episode['title'] as String?
|
||||
: null,
|
||||
episodeIds: episode is Map<String, dynamic>
|
||||
? TraktIds.fromJson(episode['ids'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
static TraktCalendarEntry? fromMovieJson(Map<String, dynamic> json) {
|
||||
final releasedStr = json['released'] as String?;
|
||||
final aired = releasedStr != null && releasedStr.isNotEmpty
|
||||
? DateTime.tryParse(releasedStr)
|
||||
: null;
|
||||
if (aired == null) return null;
|
||||
|
||||
final movie = json['movie'];
|
||||
if (movie is! Map<String, dynamic>) return null;
|
||||
|
||||
return TraktCalendarEntry(
|
||||
firstAired: aired,
|
||||
type: 'movie',
|
||||
movieTitle: movie['title'] as String?,
|
||||
movieYear: (movie['year'] as num?)?.toInt(),
|
||||
movieIds: TraktIds.fromJson(movie['ids']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) return null;
|
||||
return DateTime.tryParse(raw);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
|
||||
class TraktIds {
|
||||
final int? trakt;
|
||||
final String? imdb;
|
||||
final int? tmdb;
|
||||
final int? tvdb;
|
||||
final String? slug;
|
||||
|
||||
const TraktIds({this.trakt, this.imdb, this.tmdb, this.tvdb, this.slug});
|
||||
|
||||
factory TraktIds.fromJson(Object? raw) {
|
||||
if (raw is! Map) return const TraktIds();
|
||||
final json = raw.cast<String, dynamic>();
|
||||
return TraktIds(
|
||||
trakt: (json['trakt'] as num?)?.toInt(),
|
||||
imdb: json['imdb'] as String?,
|
||||
tmdb: (json['tmdb'] as num?)?.toInt(),
|
||||
tvdb: (json['tvdb'] as num?)?.toInt(),
|
||||
slug: json['slug'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
bool get hasReliableId =>
|
||||
(trakt != null && trakt! > 0) ||
|
||||
(imdb != null && imdb!.isNotEmpty) ||
|
||||
(tmdb != null && tmdb! > 0) ||
|
||||
(tvdb != null && tvdb! > 0);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final out = <String, dynamic>{};
|
||||
if (trakt != null && trakt! > 0) out['trakt'] = trakt;
|
||||
if (imdb != null && imdb!.isNotEmpty) out['imdb'] = imdb;
|
||||
if (tmdb != null && tmdb! > 0) out['tmdb'] = tmdb;
|
||||
if (tvdb != null && tvdb! > 0) out['tvdb'] = tvdb;
|
||||
if (slug != null && slug!.isNotEmpty) out['slug'] = slug;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'trakt_ids.dart';
|
||||
|
||||
|
||||
class TraktPlaybackEntry {
|
||||
|
||||
final double progress;
|
||||
final DateTime? pausedAt;
|
||||
|
||||
|
||||
final String type;
|
||||
|
||||
final String? movieTitle;
|
||||
final int? movieYear;
|
||||
final TraktIds? movieIds;
|
||||
|
||||
final String? showTitle;
|
||||
final int? showYear;
|
||||
final TraktIds? showIds;
|
||||
|
||||
final int? episodeSeason;
|
||||
final int? episodeNumber;
|
||||
final String? episodeTitle;
|
||||
final TraktIds? episodeIds;
|
||||
|
||||
const TraktPlaybackEntry({
|
||||
required this.progress,
|
||||
required this.type,
|
||||
this.pausedAt,
|
||||
this.movieTitle,
|
||||
this.movieYear,
|
||||
this.movieIds,
|
||||
this.showTitle,
|
||||
this.showYear,
|
||||
this.showIds,
|
||||
this.episodeSeason,
|
||||
this.episodeNumber,
|
||||
this.episodeTitle,
|
||||
this.episodeIds,
|
||||
});
|
||||
|
||||
static TraktPlaybackEntry? fromJson(Map<String, dynamic> json) {
|
||||
final movie = json['movie'];
|
||||
final show = json['show'];
|
||||
final episode = json['episode'];
|
||||
final hasMovie = movie is Map<String, dynamic>;
|
||||
final hasShow = show is Map<String, dynamic>;
|
||||
if (!hasMovie && !hasShow) return null;
|
||||
|
||||
final rawType = json['type'] as String? ?? '';
|
||||
return TraktPlaybackEntry(
|
||||
progress: (json['progress'] as num?)?.toDouble() ?? 0,
|
||||
pausedAt: _parseDate(json['paused_at']),
|
||||
type: rawType.isNotEmpty ? rawType : (hasMovie ? 'movie' : 'episode'),
|
||||
movieTitle: hasMovie ? movie['title'] as String? : null,
|
||||
movieYear: hasMovie ? (movie['year'] as num?)?.toInt() : null,
|
||||
movieIds: hasMovie ? TraktIds.fromJson(movie['ids']) : null,
|
||||
showTitle: hasShow ? show['title'] as String? : null,
|
||||
showYear: hasShow ? (show['year'] as num?)?.toInt() : null,
|
||||
showIds: hasShow ? TraktIds.fromJson(show['ids']) : null,
|
||||
episodeSeason: episode is Map<String, dynamic>
|
||||
? (episode['season'] as num?)?.toInt()
|
||||
: null,
|
||||
episodeNumber: episode is Map<String, dynamic>
|
||||
? (episode['number'] as num?)?.toInt()
|
||||
: null,
|
||||
episodeTitle: episode is Map<String, dynamic>
|
||||
? episode['title'] as String?
|
||||
: null,
|
||||
episodeIds: episode is Map<String, dynamic>
|
||||
? TraktIds.fromJson(episode['ids'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) return null;
|
||||
return DateTime.tryParse(raw);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
enum TraktResponseClass {
|
||||
|
||||
success,
|
||||
|
||||
|
||||
duplicate,
|
||||
|
||||
|
||||
invalid,
|
||||
|
||||
|
||||
rateLimited,
|
||||
|
||||
|
||||
unauthorized,
|
||||
|
||||
|
||||
transient,
|
||||
}
|
||||
|
||||
|
||||
class TraktScrobbleOutcome {
|
||||
final TraktResponseClass cls;
|
||||
|
||||
|
||||
final Duration? retryAfter;
|
||||
|
||||
const TraktScrobbleOutcome(this.cls, {this.retryAfter});
|
||||
}
|
||||
|
||||
|
||||
TraktResponseClass classifyTraktStatus(int statusCode) {
|
||||
if (statusCode >= 200 && statusCode < 300) return TraktResponseClass.success;
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
return TraktResponseClass.unauthorized;
|
||||
case 409:
|
||||
return TraktResponseClass.duplicate;
|
||||
case 422:
|
||||
return TraktResponseClass.invalid;
|
||||
case 429:
|
||||
return TraktResponseClass.rateLimited;
|
||||
default:
|
||||
return TraktResponseClass.transient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'trakt_ids.dart';
|
||||
|
||||
|
||||
enum TraktMediaKind { movie, episode }
|
||||
|
||||
|
||||
class TraktScrobbleTarget {
|
||||
final TraktMediaKind kind;
|
||||
|
||||
final String title;
|
||||
final int? year;
|
||||
final TraktIds ids;
|
||||
|
||||
final int? season;
|
||||
final int? number;
|
||||
final TraktIds episodeIds;
|
||||
|
||||
const TraktScrobbleTarget._({
|
||||
required this.kind,
|
||||
required this.title,
|
||||
required this.year,
|
||||
required this.ids,
|
||||
this.season,
|
||||
this.number,
|
||||
this.episodeIds = const TraktIds(),
|
||||
});
|
||||
|
||||
factory TraktScrobbleTarget.movie({
|
||||
required String title,
|
||||
required int? year,
|
||||
required TraktIds ids,
|
||||
}) {
|
||||
return TraktScrobbleTarget._(
|
||||
kind: TraktMediaKind.movie,
|
||||
title: title,
|
||||
year: year,
|
||||
ids: ids,
|
||||
);
|
||||
}
|
||||
|
||||
factory TraktScrobbleTarget.episode({
|
||||
required String showTitle,
|
||||
required int? showYear,
|
||||
required TraktIds showIds,
|
||||
required int? season,
|
||||
required int? number,
|
||||
required TraktIds episodeIds,
|
||||
}) {
|
||||
return TraktScrobbleTarget._(
|
||||
kind: TraktMediaKind.episode,
|
||||
title: showTitle,
|
||||
year: showYear,
|
||||
ids: showIds,
|
||||
season: season,
|
||||
number: number,
|
||||
episodeIds: episodeIds,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Map<String, dynamic> toBody({
|
||||
required double progress,
|
||||
String? appVersion,
|
||||
String? appDate,
|
||||
}) {
|
||||
final body = <String, dynamic>{};
|
||||
|
||||
switch (kind) {
|
||||
case TraktMediaKind.movie:
|
||||
body['movie'] = _mediaNode(title, year, ids);
|
||||
case TraktMediaKind.episode:
|
||||
body['show'] = _mediaNode(title, year, ids);
|
||||
final episode = <String, dynamic>{'season': season, 'number': number};
|
||||
final epIds = episodeIds.toJson();
|
||||
if (epIds.isNotEmpty) episode['ids'] = epIds;
|
||||
body['episode'] = episode;
|
||||
}
|
||||
|
||||
body['progress'] = progress.toDouble();
|
||||
if (appVersion != null && appVersion.isNotEmpty) {
|
||||
body['app_version'] = appVersion;
|
||||
}
|
||||
if (appDate != null && appDate.isNotEmpty) body['app_date'] = appDate;
|
||||
return body;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _mediaNode(
|
||||
String title,
|
||||
int? year,
|
||||
TraktIds ids,
|
||||
) {
|
||||
final node = <String, dynamic>{'title': title};
|
||||
if (year != null && year > 0) node['year'] = year;
|
||||
node['ids'] = ids.toJson();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
enum TraktSyncJobType {
|
||||
scrobbleStart('scrobble_start'),
|
||||
scrobblePause('scrobble_pause'),
|
||||
scrobbleStop('scrobble_stop'),
|
||||
historyAdd('history_add'),
|
||||
historyRemove('history_remove');
|
||||
|
||||
const TraktSyncJobType(this.wire);
|
||||
|
||||
|
||||
final String wire;
|
||||
|
||||
bool get isHistory => this == historyAdd || this == historyRemove;
|
||||
|
||||
static TraktSyncJobType fromWire(String wire) {
|
||||
return TraktSyncJobType.values.firstWhere(
|
||||
(e) => e.wire == wire,
|
||||
orElse: () => TraktSyncJobType.scrobbleStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TraktSyncJob {
|
||||
final String id;
|
||||
final TraktSyncJobType type;
|
||||
final Map<String, dynamic> body;
|
||||
final int retryCount;
|
||||
final DateTime? nextRetryAt;
|
||||
final bool exhausted;
|
||||
|
||||
const TraktSyncJob({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.body,
|
||||
this.retryCount = 0,
|
||||
this.nextRetryAt,
|
||||
this.exhausted = false,
|
||||
});
|
||||
|
||||
TraktSyncJob copyWith({
|
||||
int? retryCount,
|
||||
DateTime? nextRetryAt,
|
||||
bool clearNextRetryAt = false,
|
||||
bool? exhausted,
|
||||
}) {
|
||||
return TraktSyncJob(
|
||||
id: id,
|
||||
type: type,
|
||||
body: body,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
nextRetryAt: clearNextRetryAt ? null : (nextRetryAt ?? this.nextRetryAt),
|
||||
exhausted: exhausted ?? this.exhausted,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'type': type.wire,
|
||||
'body': body,
|
||||
'retryCount': retryCount,
|
||||
'nextRetryAt': nextRetryAt?.toIso8601String(),
|
||||
'exhausted': exhausted,
|
||||
};
|
||||
|
||||
factory TraktSyncJob.fromJson(Map<String, dynamic> json) {
|
||||
return TraktSyncJob(
|
||||
id: json['id'] as String,
|
||||
type: TraktSyncJobType.fromWire(json['type'] as String? ?? ''),
|
||||
body: (json['body'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
retryCount: (json['retryCount'] as num?)?.toInt() ?? 0,
|
||||
nextRetryAt: _parseDate(json['nextRetryAt']),
|
||||
exhausted: json['exhausted'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) return null;
|
||||
return DateTime.tryParse(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'trakt_token.freezed.dart';
|
||||
part 'trakt_token.g.dart';
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class TraktToken with _$TraktToken {
|
||||
const factory TraktToken({
|
||||
@JsonKey(name: 'access_token') @Default('') String accessToken,
|
||||
@JsonKey(name: 'refresh_token') @Default('') String refreshToken,
|
||||
@JsonKey(name: 'expires_in') @Default(0) int expiresIn,
|
||||
@JsonKey(name: 'created_at') @Default(0) int createdAt,
|
||||
@JsonKey(name: 'token_type') @Default('') String tokenType,
|
||||
@Default('') String scope,
|
||||
}) = _TraktToken;
|
||||
|
||||
factory TraktToken.fromJson(Map<String, dynamic> json) =>
|
||||
_$TraktTokenFromJson(json);
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'trakt_token.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TraktToken {
|
||||
|
||||
@JsonKey(name: 'access_token') String get accessToken;@JsonKey(name: 'refresh_token') String get refreshToken;@JsonKey(name: 'expires_in') int get expiresIn;@JsonKey(name: 'created_at') int get createdAt;@JsonKey(name: 'token_type') String get tokenType; String get scope;
|
||||
/// Create a copy of TraktToken
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$TraktTokenCopyWith<TraktToken> get copyWith => _$TraktTokenCopyWithImpl<TraktToken>(this as TraktToken, _$identity);
|
||||
|
||||
/// Serializes this TraktToken to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is TraktToken&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.expiresIn, expiresIn) || other.expiresIn == expiresIn)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.tokenType, tokenType) || other.tokenType == tokenType)&&(identical(other.scope, scope) || other.scope == scope));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,accessToken,refreshToken,expiresIn,createdAt,tokenType,scope);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TraktToken(accessToken: $accessToken, refreshToken: $refreshToken, expiresIn: $expiresIn, createdAt: $createdAt, tokenType: $tokenType, scope: $scope)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $TraktTokenCopyWith<$Res> {
|
||||
factory $TraktTokenCopyWith(TraktToken value, $Res Function(TraktToken) _then) = _$TraktTokenCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(name: 'access_token') String accessToken,@JsonKey(name: 'refresh_token') String refreshToken,@JsonKey(name: 'expires_in') int expiresIn,@JsonKey(name: 'created_at') int createdAt,@JsonKey(name: 'token_type') String tokenType, String scope
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$TraktTokenCopyWithImpl<$Res>
|
||||
implements $TraktTokenCopyWith<$Res> {
|
||||
_$TraktTokenCopyWithImpl(this._self, this._then);
|
||||
|
||||
final TraktToken _self;
|
||||
final $Res Function(TraktToken) _then;
|
||||
|
||||
/// Create a copy of TraktToken
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? accessToken = null,Object? refreshToken = null,Object? expiresIn = null,Object? createdAt = null,Object? tokenType = null,Object? scope = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,expiresIn: null == expiresIn ? _self.expiresIn : expiresIn // ignore: cast_nullable_to_non_nullable
|
||||
as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as int,tokenType: null == tokenType ? _self.tokenType : tokenType // ignore: cast_nullable_to_non_nullable
|
||||
as String,scope: null == scope ? _self.scope : scope // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [TraktToken].
|
||||
extension TraktTokenPatterns on TraktToken {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _TraktToken value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktToken() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _TraktToken value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktToken():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _TraktToken value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktToken() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'access_token') String accessToken, @JsonKey(name: 'refresh_token') String refreshToken, @JsonKey(name: 'expires_in') int expiresIn, @JsonKey(name: 'created_at') int createdAt, @JsonKey(name: 'token_type') String tokenType, String scope)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktToken() when $default != null:
|
||||
return $default(_that.accessToken,_that.refreshToken,_that.expiresIn,_that.createdAt,_that.tokenType,_that.scope);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'access_token') String accessToken, @JsonKey(name: 'refresh_token') String refreshToken, @JsonKey(name: 'expires_in') int expiresIn, @JsonKey(name: 'created_at') int createdAt, @JsonKey(name: 'token_type') String tokenType, String scope) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktToken():
|
||||
return $default(_that.accessToken,_that.refreshToken,_that.expiresIn,_that.createdAt,_that.tokenType,_that.scope);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'access_token') String accessToken, @JsonKey(name: 'refresh_token') String refreshToken, @JsonKey(name: 'expires_in') int expiresIn, @JsonKey(name: 'created_at') int createdAt, @JsonKey(name: 'token_type') String tokenType, String scope)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktToken() when $default != null:
|
||||
return $default(_that.accessToken,_that.refreshToken,_that.expiresIn,_that.createdAt,_that.tokenType,_that.scope);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _TraktToken implements TraktToken {
|
||||
const _TraktToken({@JsonKey(name: 'access_token') this.accessToken = '', @JsonKey(name: 'refresh_token') this.refreshToken = '', @JsonKey(name: 'expires_in') this.expiresIn = 0, @JsonKey(name: 'created_at') this.createdAt = 0, @JsonKey(name: 'token_type') this.tokenType = '', this.scope = ''});
|
||||
factory _TraktToken.fromJson(Map<String, dynamic> json) => _$TraktTokenFromJson(json);
|
||||
|
||||
@override@JsonKey(name: 'access_token') final String accessToken;
|
||||
@override@JsonKey(name: 'refresh_token') final String refreshToken;
|
||||
@override@JsonKey(name: 'expires_in') final int expiresIn;
|
||||
@override@JsonKey(name: 'created_at') final int createdAt;
|
||||
@override@JsonKey(name: 'token_type') final String tokenType;
|
||||
@override@JsonKey() final String scope;
|
||||
|
||||
/// Create a copy of TraktToken
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$TraktTokenCopyWith<_TraktToken> get copyWith => __$TraktTokenCopyWithImpl<_TraktToken>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$TraktTokenToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TraktToken&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.expiresIn, expiresIn) || other.expiresIn == expiresIn)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.tokenType, tokenType) || other.tokenType == tokenType)&&(identical(other.scope, scope) || other.scope == scope));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,accessToken,refreshToken,expiresIn,createdAt,tokenType,scope);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TraktToken(accessToken: $accessToken, refreshToken: $refreshToken, expiresIn: $expiresIn, createdAt: $createdAt, tokenType: $tokenType, scope: $scope)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$TraktTokenCopyWith<$Res> implements $TraktTokenCopyWith<$Res> {
|
||||
factory _$TraktTokenCopyWith(_TraktToken value, $Res Function(_TraktToken) _then) = __$TraktTokenCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
@JsonKey(name: 'access_token') String accessToken,@JsonKey(name: 'refresh_token') String refreshToken,@JsonKey(name: 'expires_in') int expiresIn,@JsonKey(name: 'created_at') int createdAt,@JsonKey(name: 'token_type') String tokenType, String scope
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$TraktTokenCopyWithImpl<$Res>
|
||||
implements _$TraktTokenCopyWith<$Res> {
|
||||
__$TraktTokenCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _TraktToken _self;
|
||||
final $Res Function(_TraktToken) _then;
|
||||
|
||||
/// Create a copy of TraktToken
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? refreshToken = null,Object? expiresIn = null,Object? createdAt = null,Object? tokenType = null,Object? scope = null,}) {
|
||||
return _then(_TraktToken(
|
||||
accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,expiresIn: null == expiresIn ? _self.expiresIn : expiresIn // ignore: cast_nullable_to_non_nullable
|
||||
as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as int,tokenType: null == tokenType ? _self.tokenType : tokenType // ignore: cast_nullable_to_non_nullable
|
||||
as String,scope: null == scope ? _self.scope : scope // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'trakt_token.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_TraktToken _$TraktTokenFromJson(Map<String, dynamic> json) => _TraktToken(
|
||||
accessToken: json['access_token'] as String? ?? '',
|
||||
refreshToken: json['refresh_token'] as String? ?? '',
|
||||
expiresIn: (json['expires_in'] as num?)?.toInt() ?? 0,
|
||||
createdAt: (json['created_at'] as num?)?.toInt() ?? 0,
|
||||
tokenType: json['token_type'] as String? ?? '',
|
||||
scope: json['scope'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TraktTokenToJson(_TraktToken instance) =>
|
||||
<String, dynamic>{
|
||||
'access_token': instance.accessToken,
|
||||
'refresh_token': instance.refreshToken,
|
||||
'expires_in': instance.expiresIn,
|
||||
'created_at': instance.createdAt,
|
||||
'token_type': instance.tokenType,
|
||||
'scope': instance.scope,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'trakt_user.freezed.dart';
|
||||
part 'trakt_user.g.dart';
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class TraktUser with _$TraktUser {
|
||||
const factory TraktUser({@Default('') String username}) = _TraktUser;
|
||||
|
||||
factory TraktUser.fromJson(Map<String, dynamic> json) =>
|
||||
_$TraktUserFromJson(json);
|
||||
}
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class TraktUserSettings with _$TraktUserSettings {
|
||||
const factory TraktUserSettings({TraktUser? user}) = _TraktUserSettings;
|
||||
|
||||
factory TraktUserSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$TraktUserSettingsFromJson(json);
|
||||
}
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'trakt_user.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TraktUser {
|
||||
|
||||
String get username;
|
||||
/// Create a copy of TraktUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$TraktUserCopyWith<TraktUser> get copyWith => _$TraktUserCopyWithImpl<TraktUser>(this as TraktUser, _$identity);
|
||||
|
||||
/// Serializes this TraktUser to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is TraktUser&&(identical(other.username, username) || other.username == username));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,username);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TraktUser(username: $username)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $TraktUserCopyWith<$Res> {
|
||||
factory $TraktUserCopyWith(TraktUser value, $Res Function(TraktUser) _then) = _$TraktUserCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String username
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$TraktUserCopyWithImpl<$Res>
|
||||
implements $TraktUserCopyWith<$Res> {
|
||||
_$TraktUserCopyWithImpl(this._self, this._then);
|
||||
|
||||
final TraktUser _self;
|
||||
final $Res Function(TraktUser) _then;
|
||||
|
||||
/// Create a copy of TraktUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? username = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [TraktUser].
|
||||
extension TraktUserPatterns on TraktUser {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _TraktUser value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUser() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _TraktUser value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUser():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _TraktUser value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUser() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String username)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUser() when $default != null:
|
||||
return $default(_that.username);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String username) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUser():
|
||||
return $default(_that.username);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String username)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUser() when $default != null:
|
||||
return $default(_that.username);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _TraktUser implements TraktUser {
|
||||
const _TraktUser({this.username = ''});
|
||||
factory _TraktUser.fromJson(Map<String, dynamic> json) => _$TraktUserFromJson(json);
|
||||
|
||||
@override@JsonKey() final String username;
|
||||
|
||||
/// Create a copy of TraktUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$TraktUserCopyWith<_TraktUser> get copyWith => __$TraktUserCopyWithImpl<_TraktUser>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$TraktUserToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TraktUser&&(identical(other.username, username) || other.username == username));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,username);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TraktUser(username: $username)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$TraktUserCopyWith<$Res> implements $TraktUserCopyWith<$Res> {
|
||||
factory _$TraktUserCopyWith(_TraktUser value, $Res Function(_TraktUser) _then) = __$TraktUserCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String username
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$TraktUserCopyWithImpl<$Res>
|
||||
implements _$TraktUserCopyWith<$Res> {
|
||||
__$TraktUserCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _TraktUser _self;
|
||||
final $Res Function(_TraktUser) _then;
|
||||
|
||||
/// Create a copy of TraktUser
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? username = null,}) {
|
||||
return _then(_TraktUser(
|
||||
username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TraktUserSettings {
|
||||
|
||||
TraktUser? get user;
|
||||
/// Create a copy of TraktUserSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$TraktUserSettingsCopyWith<TraktUserSettings> get copyWith => _$TraktUserSettingsCopyWithImpl<TraktUserSettings>(this as TraktUserSettings, _$identity);
|
||||
|
||||
/// Serializes this TraktUserSettings to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is TraktUserSettings&&(identical(other.user, user) || other.user == user));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,user);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TraktUserSettings(user: $user)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $TraktUserSettingsCopyWith<$Res> {
|
||||
factory $TraktUserSettingsCopyWith(TraktUserSettings value, $Res Function(TraktUserSettings) _then) = _$TraktUserSettingsCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
TraktUser? user
|
||||
});
|
||||
|
||||
|
||||
$TraktUserCopyWith<$Res>? get user;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$TraktUserSettingsCopyWithImpl<$Res>
|
||||
implements $TraktUserSettingsCopyWith<$Res> {
|
||||
_$TraktUserSettingsCopyWithImpl(this._self, this._then);
|
||||
|
||||
final TraktUserSettings _self;
|
||||
final $Res Function(TraktUserSettings) _then;
|
||||
|
||||
/// Create a copy of TraktUserSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? user = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
|
||||
as TraktUser?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of TraktUserSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$TraktUserCopyWith<$Res>? get user {
|
||||
if (_self.user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $TraktUserCopyWith<$Res>(_self.user!, (value) {
|
||||
return _then(_self.copyWith(user: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [TraktUserSettings].
|
||||
extension TraktUserSettingsPatterns on TraktUserSettings {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _TraktUserSettings value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUserSettings() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _TraktUserSettings value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUserSettings():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _TraktUserSettings value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUserSettings() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TraktUser? user)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUserSettings() when $default != null:
|
||||
return $default(_that.user);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TraktUser? user) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUserSettings():
|
||||
return $default(_that.user);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TraktUser? user)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _TraktUserSettings() when $default != null:
|
||||
return $default(_that.user);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _TraktUserSettings implements TraktUserSettings {
|
||||
const _TraktUserSettings({this.user});
|
||||
factory _TraktUserSettings.fromJson(Map<String, dynamic> json) => _$TraktUserSettingsFromJson(json);
|
||||
|
||||
@override final TraktUser? user;
|
||||
|
||||
/// Create a copy of TraktUserSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$TraktUserSettingsCopyWith<_TraktUserSettings> get copyWith => __$TraktUserSettingsCopyWithImpl<_TraktUserSettings>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$TraktUserSettingsToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TraktUserSettings&&(identical(other.user, user) || other.user == user));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,user);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TraktUserSettings(user: $user)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$TraktUserSettingsCopyWith<$Res> implements $TraktUserSettingsCopyWith<$Res> {
|
||||
factory _$TraktUserSettingsCopyWith(_TraktUserSettings value, $Res Function(_TraktUserSettings) _then) = __$TraktUserSettingsCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
TraktUser? user
|
||||
});
|
||||
|
||||
|
||||
@override $TraktUserCopyWith<$Res>? get user;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$TraktUserSettingsCopyWithImpl<$Res>
|
||||
implements _$TraktUserSettingsCopyWith<$Res> {
|
||||
__$TraktUserSettingsCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _TraktUserSettings _self;
|
||||
final $Res Function(_TraktUserSettings) _then;
|
||||
|
||||
/// Create a copy of TraktUserSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? user = freezed,}) {
|
||||
return _then(_TraktUserSettings(
|
||||
user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
|
||||
as TraktUser?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of TraktUserSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$TraktUserCopyWith<$Res>? get user {
|
||||
if (_self.user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $TraktUserCopyWith<$Res>(_self.user!, (value) {
|
||||
return _then(_self.copyWith(user: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'trakt_user.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_TraktUser _$TraktUserFromJson(Map<String, dynamic> json) =>
|
||||
_TraktUser(username: json['username'] as String? ?? '');
|
||||
|
||||
Map<String, dynamic> _$TraktUserToJson(_TraktUser instance) =>
|
||||
<String, dynamic>{'username': instance.username};
|
||||
|
||||
_TraktUserSettings _$TraktUserSettingsFromJson(Map<String, dynamic> json) =>
|
||||
_TraktUserSettings(
|
||||
user: json['user'] == null
|
||||
? null
|
||||
: TraktUser.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TraktUserSettingsToJson(_TraktUserSettings instance) =>
|
||||
<String, dynamic>{'user': instance.user};
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../contracts/error.dart';
|
||||
|
||||
class DomainError implements Exception {
|
||||
final ErrorCode code;
|
||||
final String message;
|
||||
final bool retryable;
|
||||
final Object? details;
|
||||
|
||||
DomainError(this.code, this.message, {this.retryable = false, this.details});
|
||||
|
||||
@override
|
||||
String toString() => 'DomainError(${code.value}): $message';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '../../contracts/trakt/trakt_response.dart';
|
||||
import '../../contracts/trakt/trakt_sync_job.dart';
|
||||
|
||||
|
||||
abstract class AuthedTraktGateway {
|
||||
|
||||
Future<bool> isConnected();
|
||||
|
||||
|
||||
Future<TraktScrobbleOutcome> scrobble(
|
||||
TraktSyncJobType type,
|
||||
Map<String, dynamic> body,
|
||||
);
|
||||
|
||||
|
||||
Future<TraktScrobbleOutcome> syncHistory(
|
||||
TraktSyncJobType type,
|
||||
Map<String, dynamic> body,
|
||||
);
|
||||
|
||||
|
||||
Future<List<Map<String, dynamic>>> fetchPlayback();
|
||||
|
||||
|
||||
Future<List<Map<String, dynamic>>> fetchCalendarShows();
|
||||
|
||||
|
||||
Future<List<Map<String, dynamic>>> fetchCalendarMovies();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../../contracts/danmaku.dart';
|
||||
|
||||
abstract class DanmakuGateway {
|
||||
|
||||
|
||||
Future<List<DanmakuMatch>> match(
|
||||
String baseUrl,
|
||||
String fileName,
|
||||
int durationSec,
|
||||
);
|
||||
|
||||
|
||||
Future<List<DanmakuMatch>> searchEpisodes(
|
||||
String baseUrl,
|
||||
String anime,
|
||||
int? episode,
|
||||
);
|
||||
|
||||
|
||||
Future<List<DanmakuComment>> fetchComments(
|
||||
String baseUrl,
|
||||
int episodeId, {
|
||||
int chConvert = 0,
|
||||
});
|
||||
|
||||
|
||||
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
|
||||
String baseUrl,
|
||||
int animeId,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import '../../contracts/danmaku.dart';
|
||||
|
||||
|
||||
abstract class DanmakuSourcesStore {
|
||||
|
||||
Future<List<DanmakuSource>> list();
|
||||
|
||||
|
||||
Future<void> replaceAll(List<DanmakuSource> sources);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import '../../contracts/cancellation.dart';
|
||||
import '../../contracts/library.dart';
|
||||
import '../../contracts/playback.dart';
|
||||
import '../../contracts/server.dart';
|
||||
|
||||
|
||||
class AuthedRequestContext {
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
final String userId;
|
||||
|
||||
const AuthedRequestContext({
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
required this.userId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class EmbyAuthResult {
|
||||
final String accessToken;
|
||||
final String userId;
|
||||
final String userName;
|
||||
|
||||
const EmbyAuthResult({
|
||||
required this.accessToken,
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
abstract class EmbyGateway {
|
||||
Future<ProbePublicInfoRes> probePublicInfo(String baseUrl);
|
||||
|
||||
Future<EmbyAuthResult> authenticateByName({
|
||||
required String baseUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
});
|
||||
|
||||
Future<void> changePassword({
|
||||
required AuthedRequestContext ctx,
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
});
|
||||
|
||||
Future<EmbyRawLibraries> getUserViews(AuthedRequestContext ctx);
|
||||
|
||||
Future<LibraryLatestRes> getLatestItems(
|
||||
AuthedRequestContext ctx,
|
||||
String parentId, {
|
||||
int? limit,
|
||||
CancellationToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<LibraryResumeRes> getResumeItems(
|
||||
AuthedRequestContext ctx, {
|
||||
int? limit,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
Future<List<EmbyRawItem>> getFavoriteItems({
|
||||
required AuthedRequestContext ctx,
|
||||
required String includeItemTypes,
|
||||
required String sortBy,
|
||||
SortOrder? sortOrder,
|
||||
});
|
||||
|
||||
Future<ItemCountsRes> getItemCounts(AuthedRequestContext ctx);
|
||||
|
||||
Future<EmbyRawItem> getItemDetail(AuthedRequestContext ctx, String itemId);
|
||||
|
||||
Future<List<EmbyRawItem>> getSeriesNextUp(
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId, {
|
||||
int? limit,
|
||||
});
|
||||
|
||||
Future<List<EmbyRawSeason>> getSeriesSeasons(
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId, {
|
||||
CancellationToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<List<EmbyRawItem>> getSeriesEpisodes(
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId,
|
||||
String seasonId, {
|
||||
CancellationToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<List<EmbyRawItem>> getSimilarItems(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId, {
|
||||
int? limit,
|
||||
});
|
||||
|
||||
Future<List<EmbyRawItem>> getAdditionalParts(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId,
|
||||
);
|
||||
|
||||
Future<List<EmbyRawItem>> getSpecialFeatures(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId,
|
||||
);
|
||||
|
||||
Future<bool> setFavoriteStatus({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
required bool isFavorite,
|
||||
});
|
||||
|
||||
Future<bool> setPlayedStatus({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
required bool played,
|
||||
});
|
||||
|
||||
Future<bool> hideFromResume({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
required bool hide,
|
||||
});
|
||||
|
||||
Future<void> reportPlaybackStarted({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
});
|
||||
|
||||
Future<void> reportPlaybackProgress({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
});
|
||||
|
||||
Future<void> reportPlaybackStopped({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
});
|
||||
|
||||
|
||||
Future<Map<String, dynamic>> fetchPlaybackInfo({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
String? mediaSourceId,
|
||||
int startTimeTicks = 0,
|
||||
bool isPlayback = false,
|
||||
required Map<String, dynamic> deviceProfile,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
abstract class ScriptWidgetRemoteGateway {
|
||||
Future<String> fetchText(String url);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import '../../contracts/script_widget.dart';
|
||||
|
||||
abstract class ScriptWidgetRepository {
|
||||
Future<List<InstalledScriptWidget>> listWidgets();
|
||||
|
||||
Future<InstalledScriptWidget?> findWidget(String widgetId);
|
||||
|
||||
Future<void> upsertWidget(InstalledScriptWidget widget);
|
||||
|
||||
Future<void> deleteWidget(String widgetId);
|
||||
|
||||
Future<List<ScriptWidgetSubscription>> listSubscriptions();
|
||||
|
||||
Future<void> upsertSubscription(ScriptWidgetSubscription subscription);
|
||||
|
||||
|
||||
Future<void> replaceAllWidgets(List<InstalledScriptWidget> widgets);
|
||||
|
||||
|
||||
Future<void> replaceAllSubscriptions(
|
||||
List<ScriptWidgetSubscription> subscriptions,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../../contracts/script_widget.dart';
|
||||
|
||||
abstract class ScriptWidgetRuntime {
|
||||
Future<ScriptWidgetManifest> inspectWidget(String scriptSource);
|
||||
|
||||
Future<ScriptWidgetManifest> loadWidget(String scriptSource);
|
||||
|
||||
bool isWidgetLoaded(String widgetId);
|
||||
|
||||
Future<Object?> invokeModuleFunction(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Map<String, Object?> params,
|
||||
);
|
||||
|
||||
Future<void> unloadWidget(String widgetId);
|
||||
|
||||
Future<void> dispose();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../../contracts/server.dart';
|
||||
|
||||
abstract class ServerRepository {
|
||||
Future<List<EmbyServer>> list();
|
||||
Future<EmbyServer?> findById(String id);
|
||||
Future<EmbyServer?> findByBaseUrl(String baseUrl);
|
||||
Future<EmbyServer> create(EmbyServer server);
|
||||
Future<EmbyServer> update(EmbyServer server);
|
||||
Future<void> deleteById(String id);
|
||||
Future<void> touchConnectedAt(String id, String updatedAt);
|
||||
|
||||
|
||||
Future<void> replaceAll(List<EmbyServer> servers);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
|
||||
library;
|
||||
|
||||
|
||||
class ServerSyncSnapshot {
|
||||
final String? blob;
|
||||
|
||||
const ServerSyncSnapshot({this.blob});
|
||||
|
||||
bool get isEmpty => blob == null || blob!.trim().isEmpty;
|
||||
}
|
||||
|
||||
abstract class ServerSyncGateway {
|
||||
|
||||
|
||||
Future<ServerSyncSnapshot> pull({
|
||||
required String baseUrl,
|
||||
required String accessToken,
|
||||
});
|
||||
|
||||
|
||||
Future<void> push({
|
||||
required String baseUrl,
|
||||
required String accessToken,
|
||||
required String blob,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../../contracts/auth.dart';
|
||||
|
||||
abstract class SessionRepository {
|
||||
Future<SessionData> set(SessionData session);
|
||||
|
||||
|
||||
Future<void> setMany(List<SessionData> sessions);
|
||||
|
||||
|
||||
Future<void> replaceAll(List<SessionData> sessions, {String? activeServerId});
|
||||
|
||||
Future<SessionData?> getByServerId(String serverId);
|
||||
Future<SessionData?> getActive();
|
||||
Future<String?> getActiveServerId();
|
||||
Future<List<SessionData>> listAll();
|
||||
Future<void> setActive(String serverId);
|
||||
Future<void> clear([String? serverId]);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
|
||||
|
||||
library;
|
||||
|
||||
|
||||
class SmLoginResult {
|
||||
final bool ok;
|
||||
final String email;
|
||||
|
||||
|
||||
final String accessToken;
|
||||
|
||||
|
||||
final String refreshToken;
|
||||
|
||||
final int expiresIn;
|
||||
|
||||
|
||||
final bool isVip;
|
||||
|
||||
|
||||
final int? vipExpiresAt;
|
||||
|
||||
|
||||
final bool vipPermanent;
|
||||
|
||||
|
||||
final bool needsVerification;
|
||||
|
||||
final String? message;
|
||||
|
||||
const SmLoginResult({
|
||||
required this.ok,
|
||||
this.email = '',
|
||||
this.accessToken = '',
|
||||
this.refreshToken = '',
|
||||
this.expiresIn = 0,
|
||||
this.isVip = false,
|
||||
this.vipExpiresAt,
|
||||
this.vipPermanent = false,
|
||||
this.needsVerification = false,
|
||||
this.message,
|
||||
});
|
||||
|
||||
factory SmLoginResult.failure(
|
||||
String message, {
|
||||
bool needsVerification = false,
|
||||
}) => SmLoginResult(
|
||||
ok: false,
|
||||
needsVerification: needsVerification,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class SmTokenRefreshResult {
|
||||
final bool ok;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
|
||||
|
||||
final bool isVip;
|
||||
final int? vipExpiresAt;
|
||||
final bool vipPermanent;
|
||||
|
||||
|
||||
final bool hasVipInfo;
|
||||
|
||||
|
||||
final bool invalidRefreshToken;
|
||||
final String? message;
|
||||
|
||||
const SmTokenRefreshResult({
|
||||
required this.ok,
|
||||
this.accessToken = '',
|
||||
this.refreshToken = '',
|
||||
this.expiresIn = 0,
|
||||
this.isVip = false,
|
||||
this.vipExpiresAt,
|
||||
this.vipPermanent = false,
|
||||
this.hasVipInfo = false,
|
||||
this.invalidRefreshToken = false,
|
||||
this.message,
|
||||
});
|
||||
|
||||
factory SmTokenRefreshResult.failure(
|
||||
String message, {
|
||||
bool invalidRefreshToken = false,
|
||||
}) => SmTokenRefreshResult(
|
||||
ok: false,
|
||||
invalidRefreshToken: invalidRefreshToken,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class SmActionResult {
|
||||
final bool ok;
|
||||
final String? message;
|
||||
|
||||
const SmActionResult({required this.ok, this.message});
|
||||
|
||||
factory SmActionResult.success() => const SmActionResult(ok: true);
|
||||
|
||||
factory SmActionResult.failure(String message) =>
|
||||
SmActionResult(ok: false, message: message);
|
||||
}
|
||||
|
||||
|
||||
abstract class SmAccountGateway {
|
||||
|
||||
|
||||
Future<SmLoginResult> login({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
required String password,
|
||||
});
|
||||
|
||||
|
||||
Future<SmActionResult> registerStart({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
});
|
||||
|
||||
|
||||
Future<SmLoginResult> registerVerify({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
required String password,
|
||||
required String code,
|
||||
});
|
||||
|
||||
|
||||
Future<SmActionResult> resendCode({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
});
|
||||
|
||||
|
||||
Future<void> logout({required String baseUrl, required String refreshToken});
|
||||
|
||||
|
||||
Future<SmTokenRefreshResult> refreshAccessToken({
|
||||
required String baseUrl,
|
||||
required String refreshToken,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import '../../contracts/tmdb.dart';
|
||||
|
||||
|
||||
class TmdbRequestConfig {
|
||||
final String apiBaseUrl;
|
||||
final String imageBaseUrl;
|
||||
final String accessToken;
|
||||
final String apiKey;
|
||||
|
||||
|
||||
final Future<String?> Function()? onUnauthorized;
|
||||
|
||||
const TmdbRequestConfig({
|
||||
required this.apiBaseUrl,
|
||||
required this.imageBaseUrl,
|
||||
this.accessToken = '',
|
||||
this.apiKey = '',
|
||||
this.onUnauthorized,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class TmdbGateway {
|
||||
Future<TmdbCreditsRes?> getCredits(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbRecommendationsRes?> getRecommendations(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbSeasonDetail?> getSeasonDetail(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
int seasonNumber, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbImageSet?> getImages(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbEnrichedDetail?> getEnrichedDetail(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
List<String> appendToResponse,
|
||||
});
|
||||
|
||||
Future<TmdbVideosRes?> getVideos(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbPersonDetail?> getPersonDetail(
|
||||
TmdbRequestConfig config,
|
||||
int personId, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbPersonCreditsRes?> getPersonCredits(
|
||||
TmdbRequestConfig config,
|
||||
int personId, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbMediaDetail?> getMediaDetail(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbRecommendationsRes?> getTrending(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
String mediaType,
|
||||
String timeWindow,
|
||||
});
|
||||
|
||||
Future<TmdbRecommendationsRes?> getPopularMovies(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbRecommendationsRes?> getPopularTv(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbRecommendationsRes?> getAiringTodayTv(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
Future<TmdbRecommendationsRes?> getTopRatedMovies(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
});
|
||||
|
||||
|
||||
Future<TmdbRecommendationsRes?> getDiscover(
|
||||
TmdbRequestConfig config,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
String sortBy = 'popularity.desc',
|
||||
String? withGenres,
|
||||
String? voteCountGte,
|
||||
});
|
||||
|
||||
Future<bool> testConnection(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../../contracts/trakt/trakt_token.dart';
|
||||
import '../../contracts/trakt/trakt_user.dart';
|
||||
|
||||
|
||||
abstract class TraktGateway {
|
||||
|
||||
Future<TraktToken> exchangeCode(String code);
|
||||
|
||||
|
||||
Future<TraktToken> refreshToken(String refreshToken);
|
||||
|
||||
|
||||
Future<TraktUser> getMe(String accessToken);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../errors.dart';
|
||||
import '../ports/emby_gateway.dart';
|
||||
import '../ports/server_repository.dart';
|
||||
import '../ports/session_repository.dart';
|
||||
|
||||
class AuthByNameUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
final SessionRepository sessionRepository;
|
||||
final EmbyGateway embyGateway;
|
||||
final DateTime Function() now;
|
||||
|
||||
AuthByNameUseCase({
|
||||
required this.serverRepository,
|
||||
required this.sessionRepository,
|
||||
required this.embyGateway,
|
||||
DateTime Function()? now,
|
||||
}) : now = now ?? DateTime.now;
|
||||
|
||||
Future<void> execute(AuthByNameReq input) async {
|
||||
final username = input.username.trim();
|
||||
final password = input.password;
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '用户名或密码不能为空');
|
||||
}
|
||||
|
||||
final server = await serverRepository.findById(input.serverId);
|
||||
if (server == null) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
'服务器不存在',
|
||||
details: {'serverId': input.serverId},
|
||||
);
|
||||
}
|
||||
|
||||
final auth = await embyGateway.authenticateByName(
|
||||
baseUrl: server.baseUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final nowIso = now().toUtc().toIso8601String();
|
||||
await sessionRepository.set(
|
||||
SessionData(
|
||||
serverId: server.id,
|
||||
token: auth.accessToken,
|
||||
userId: auth.userId,
|
||||
userName: auth.userName,
|
||||
password: password,
|
||||
createdAt: nowIso,
|
||||
),
|
||||
);
|
||||
await serverRepository.touchConnectedAt(server.id, nowIso);
|
||||
}
|
||||
}
|
||||
|
||||
class LogoutUseCase {
|
||||
final SessionRepository sessionRepository;
|
||||
LogoutUseCase({required this.sessionRepository});
|
||||
|
||||
Future<void> execute([String? serverId]) => sessionRepository.clear(serverId);
|
||||
}
|
||||
|
||||
class ListSessionsUseCase {
|
||||
final SessionRepository sessionRepository;
|
||||
final ServerRepository serverRepository;
|
||||
|
||||
ListSessionsUseCase({
|
||||
required this.sessionRepository,
|
||||
required this.serverRepository,
|
||||
});
|
||||
|
||||
Future<SessionListRes> execute() async {
|
||||
final sessions = await sessionRepository.listAll();
|
||||
final activeServerId = await sessionRepository.getActiveServerId();
|
||||
final result = <AuthedSession>[];
|
||||
|
||||
for (final session in sessions) {
|
||||
final server = await serverRepository.findById(session.serverId);
|
||||
if (server != null) {
|
||||
result.add(
|
||||
AuthedSession(
|
||||
serverId: session.serverId,
|
||||
serverName: server.name,
|
||||
serverUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
password: session.password,
|
||||
isActive: session.serverId == activeServerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return SessionListRes(sessions: result, activeServerId: activeServerId);
|
||||
}
|
||||
}
|
||||
|
||||
class SwitchSessionUseCase {
|
||||
final SessionRepository sessionRepository;
|
||||
|
||||
SwitchSessionUseCase({required this.sessionRepository});
|
||||
|
||||
Future<void> execute(String serverId) async {
|
||||
final session = await sessionRepository.getByServerId(serverId);
|
||||
if (session == null) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
'该服务器没有可用会话',
|
||||
details: {'serverId': serverId},
|
||||
);
|
||||
}
|
||||
await sessionRepository.setActive(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
class ChangePasswordUseCase {
|
||||
final SessionRepository sessionRepository;
|
||||
final ServerRepository serverRepository;
|
||||
final EmbyGateway embyGateway;
|
||||
|
||||
ChangePasswordUseCase({
|
||||
required this.sessionRepository,
|
||||
required this.serverRepository,
|
||||
required this.embyGateway,
|
||||
});
|
||||
|
||||
Future<void> execute({
|
||||
required String serverId,
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
if (currentPassword.isEmpty || newPassword.isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '密码不能为空');
|
||||
}
|
||||
|
||||
final session = await sessionRepository.getByServerId(serverId);
|
||||
if (session == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '该服务器没有可用会话');
|
||||
}
|
||||
final server = await serverRepository.findById(serverId);
|
||||
if (server == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '服务器不存在');
|
||||
}
|
||||
|
||||
final ctx = AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
|
||||
await embyGateway.changePassword(
|
||||
ctx: ctx,
|
||||
currentPassword: currentPassword,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
|
||||
await sessionRepository.setMany([session.copyWith(password: newPassword)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../errors.dart';
|
||||
import '../ports/danmaku_sources_store.dart';
|
||||
import '../ports/script_widget_repository.dart';
|
||||
import '../ports/server_repository.dart';
|
||||
import '../ports/session_repository.dart';
|
||||
|
||||
const int kBackupSchemaVersion = 1;
|
||||
|
||||
class BackupExportResult {
|
||||
final String json;
|
||||
final int serverCount;
|
||||
final int sessionCount;
|
||||
final int danmakuSourceCount;
|
||||
final int scriptWidgetCount;
|
||||
final int scriptWidgetSubscriptionCount;
|
||||
|
||||
const BackupExportResult({
|
||||
required this.json,
|
||||
required this.serverCount,
|
||||
required this.sessionCount,
|
||||
required this.danmakuSourceCount,
|
||||
required this.scriptWidgetCount,
|
||||
required this.scriptWidgetSubscriptionCount,
|
||||
});
|
||||
}
|
||||
|
||||
class BackupImportResult {
|
||||
final int serverCount;
|
||||
final int sessionCount;
|
||||
final int danmakuSourceCount;
|
||||
final int scriptWidgetCount;
|
||||
final int scriptWidgetSubscriptionCount;
|
||||
|
||||
const BackupImportResult({
|
||||
required this.serverCount,
|
||||
required this.sessionCount,
|
||||
required this.danmakuSourceCount,
|
||||
required this.scriptWidgetCount,
|
||||
required this.scriptWidgetSubscriptionCount,
|
||||
});
|
||||
}
|
||||
|
||||
class ExportBackupUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
final SessionRepository sessionRepository;
|
||||
final DanmakuSourcesStore danmakuSourcesStore;
|
||||
final ScriptWidgetRepository scriptWidgetRepository;
|
||||
final DateTime Function() now;
|
||||
|
||||
ExportBackupUseCase({
|
||||
required this.serverRepository,
|
||||
required this.sessionRepository,
|
||||
required this.danmakuSourcesStore,
|
||||
required this.scriptWidgetRepository,
|
||||
DateTime Function()? now,
|
||||
}) : now = (now ?? DateTime.now);
|
||||
|
||||
Future<BackupExportResult> execute() async {
|
||||
final servers = await serverRepository.list();
|
||||
final sessions = await sessionRepository.listAll();
|
||||
final danmakuSources = await danmakuSourcesStore.list();
|
||||
final scriptWidgets = await scriptWidgetRepository.listWidgets();
|
||||
final scriptWidgetSubscriptions =
|
||||
await scriptWidgetRepository.listSubscriptions();
|
||||
final bundle = BackupBundle(
|
||||
version: kBackupSchemaVersion,
|
||||
exportedAt: now().toUtc().toIso8601String(),
|
||||
servers: servers,
|
||||
sessions: sessions,
|
||||
danmakuSources: danmakuSources,
|
||||
scriptWidgets: scriptWidgets,
|
||||
scriptWidgetSubscriptions: scriptWidgetSubscriptions,
|
||||
);
|
||||
final json = const JsonEncoder.withIndent(' ').convert(bundle.toJson());
|
||||
return BackupExportResult(
|
||||
json: json,
|
||||
serverCount: servers.length,
|
||||
sessionCount: sessions.length,
|
||||
danmakuSourceCount: danmakuSources.length,
|
||||
scriptWidgetCount: scriptWidgets.length,
|
||||
scriptWidgetSubscriptionCount: scriptWidgetSubscriptions.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImportBackupUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
final SessionRepository sessionRepository;
|
||||
final DanmakuSourcesStore danmakuSourcesStore;
|
||||
final ScriptWidgetRepository scriptWidgetRepository;
|
||||
|
||||
ImportBackupUseCase({
|
||||
required this.serverRepository,
|
||||
required this.sessionRepository,
|
||||
required this.danmakuSourcesStore,
|
||||
required this.scriptWidgetRepository,
|
||||
});
|
||||
|
||||
Future<BackupImportResult> execute(String rawJson) async {
|
||||
final BackupBundle bundle;
|
||||
try {
|
||||
final decoded = jsonDecode(rawJson);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw DomainError(ErrorCode.invalidParams, '备份文件格式不正确');
|
||||
}
|
||||
bundle = BackupBundle.fromJson(decoded);
|
||||
} on DomainError {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'备份文件解析失败',
|
||||
details: {'reason': e.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
if (bundle.version != kBackupSchemaVersion) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'不支持的备份文件版本:${bundle.version}',
|
||||
details: {'expected': kBackupSchemaVersion, 'got': bundle.version},
|
||||
);
|
||||
}
|
||||
|
||||
for (final server in bundle.servers) {
|
||||
await serverRepository.update(server);
|
||||
}
|
||||
await sessionRepository.setMany(bundle.sessions);
|
||||
|
||||
final mergedSources = mergeDanmakuSourcesByUrl(
|
||||
local: await danmakuSourcesStore.list(),
|
||||
remote: bundle.danmakuSources,
|
||||
);
|
||||
await danmakuSourcesStore.replaceAll(mergedSources);
|
||||
|
||||
for (final scriptWidget in bundle.scriptWidgets) {
|
||||
await scriptWidgetRepository.upsertWidget(scriptWidget);
|
||||
}
|
||||
for (final subscription in bundle.scriptWidgetSubscriptions) {
|
||||
await scriptWidgetRepository.upsertSubscription(subscription);
|
||||
}
|
||||
|
||||
return BackupImportResult(
|
||||
serverCount: bundle.servers.length,
|
||||
sessionCount: bundle.sessions.length,
|
||||
danmakuSourceCount: mergedSources.length,
|
||||
scriptWidgetCount: bundle.scriptWidgets.length,
|
||||
scriptWidgetSubscriptionCount: bundle.scriptWidgetSubscriptions.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
List<DanmakuSource> mergeDanmakuSourcesByUrl({
|
||||
required List<DanmakuSource> local,
|
||||
required List<DanmakuSource> remote,
|
||||
}) {
|
||||
final localByUrl = <String, DanmakuSource>{
|
||||
for (final s in local) s.url.trim(): s,
|
||||
};
|
||||
final out = <DanmakuSource>[];
|
||||
final seen = <String>{};
|
||||
for (final r in remote) {
|
||||
final url = r.url.trim();
|
||||
if (url.isEmpty || seen.contains(url)) continue;
|
||||
seen.add(url);
|
||||
final hit = localByUrl[url];
|
||||
if (hit != null) {
|
||||
out.add(hit.copyWith(name: hit.name.trim().isEmpty ? r.name : hit.name));
|
||||
} else {
|
||||
out.add(
|
||||
DanmakuSource(
|
||||
id: r.id.trim().isEmpty
|
||||
? 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}'
|
||||
: r.id,
|
||||
name: r.name,
|
||||
url: url,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../ports/emby_gateway.dart';
|
||||
import '../ports/server_repository.dart';
|
||||
import '../ports/session_repository.dart';
|
||||
import 'usecase_helpers.dart';
|
||||
|
||||
const _ucTag = 'UseCase';
|
||||
|
||||
|
||||
Stream<GlobalSearchServerResult> _fanOutPerServer({
|
||||
required SessionRepository sessionRepository,
|
||||
required ServerRepository serverRepository,
|
||||
required Future<List<EmbyRawItem>?> Function(AuthedRequestContext context)
|
||||
fetchItems,
|
||||
required String logTag,
|
||||
}) {
|
||||
final controller = StreamController<GlobalSearchServerResult>();
|
||||
() async {
|
||||
try {
|
||||
final allSessions = await sessionRepository.listAll();
|
||||
final allServers = await serverRepository.list();
|
||||
final serverMap = {for (final s in allServers) s.id: s};
|
||||
|
||||
final futures = allSessions.map((session) async {
|
||||
final server = serverMap[session.serverId];
|
||||
if (server == null) return;
|
||||
final context = AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
try {
|
||||
final items = await fetchItems(context);
|
||||
if (items == null || items.isEmpty) return;
|
||||
controller.add(
|
||||
GlobalSearchServerResult(
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
serverUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
items: items,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_ucTag, '$logTag on ${server.name} failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
await Future.wait(futures);
|
||||
} catch (e) {
|
||||
controller.addError(e);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
}();
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
class GetLibraryViewsUseCase extends AuthedUseCase {
|
||||
GetLibraryViewsUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<LibraryViewsRes> execute() async {
|
||||
return embyGateway.getUserViews(await ctx());
|
||||
}
|
||||
}
|
||||
|
||||
class GetLibraryResumeUseCase extends AuthedUseCase {
|
||||
GetLibraryResumeUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<LibraryResumeRes> execute() async {
|
||||
return embyGateway.getResumeItems(await ctx(), limit: 20);
|
||||
}
|
||||
}
|
||||
|
||||
class GetLibraryLatestUseCase extends AuthedUseCase {
|
||||
GetLibraryLatestUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<LibraryLatestRes> execute(
|
||||
LibraryLatestReq input, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
return embyGateway.getLatestItems(
|
||||
await ctx(),
|
||||
input.parentId,
|
||||
limit: input.limit ?? 16,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetLibraryItemsUseCase extends AuthedUseCase {
|
||||
GetLibraryItemsUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<LibraryItemsRes> execute(LibraryItemsReq input) async {
|
||||
final items = await embyGateway.getLibraryItems(
|
||||
ctx: await ctx(),
|
||||
parentId: input.parentId,
|
||||
includeItemTypes: input.includeItemTypes,
|
||||
genreIds: input.genreIds,
|
||||
searchTerm: input.searchTerm,
|
||||
anyProviderIdEquals: input.anyProviderIdEquals,
|
||||
fields: input.fields,
|
||||
enableImageTypes: input.enableImageTypes,
|
||||
groupProgramsBySeries: input.groupProgramsBySeries,
|
||||
imageTypeLimit: input.imageTypeLimit,
|
||||
startIndex: input.startIndex,
|
||||
limit: input.limit,
|
||||
recursive: input.recursive,
|
||||
sortBy: input.sortBy,
|
||||
sortOrder: input.sortOrder,
|
||||
);
|
||||
return LibraryItemsRes(
|
||||
parentId: input.parentId ?? '__global__',
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class GetHomeBannerUseCase extends AuthedUseCase {
|
||||
GetHomeBannerUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<List<EmbyRawItem>> execute({int fetchLimit = 24, int take = 8}) async {
|
||||
final items = await embyGateway.getLibraryItems(
|
||||
ctx: await ctx(),
|
||||
includeItemTypes: 'Movie,Series',
|
||||
recursive: true,
|
||||
sortBy: 'DateCreated',
|
||||
sortOrder: SortOrder.descending,
|
||||
fields:
|
||||
'BasicSyncInfo,CommunityRating,ProductionYear,Overview,Genres,ProviderIds,UserData',
|
||||
enableImageTypes: 'Backdrop,Logo,Primary,Thumb',
|
||||
imageTypeLimit: 2,
|
||||
limit: fetchLimit,
|
||||
);
|
||||
return items
|
||||
.where((it) => (it.BackdropImageTags ?? const []).isNotEmpty)
|
||||
.take(take)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
class GlobalKeywordSearchUseCase extends AuthedUseCase {
|
||||
GlobalKeywordSearchUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
static const _defaultTypes = 'Movie,Series';
|
||||
|
||||
Stream<GlobalSearchServerResult> executeStream(GlobalSearchReq input) {
|
||||
final controller = StreamController<GlobalSearchServerResult>();
|
||||
() async {
|
||||
final keyword = input.keyword.trim();
|
||||
if (keyword.isEmpty) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var sessions = await sessionRepository.listAll();
|
||||
if (input.serverId != null) {
|
||||
sessions = sessions
|
||||
.where((s) => s.serverId == input.serverId)
|
||||
.toList();
|
||||
}
|
||||
final allServers = await serverRepository.list();
|
||||
final serverMap = {for (final s in allServers) s.id: s};
|
||||
final types = input.includeItemTypes ?? _defaultTypes;
|
||||
|
||||
final futures = sessions.map((session) async {
|
||||
final result = await _searchOne(
|
||||
session: session,
|
||||
server: serverMap[session.serverId],
|
||||
keyword: keyword,
|
||||
types: types,
|
||||
limit: input.limitPerServer ?? 30,
|
||||
);
|
||||
if (result != null) controller.add(result);
|
||||
});
|
||||
|
||||
await Future.wait(futures);
|
||||
} catch (error, stackTrace) {
|
||||
controller.addError(error, stackTrace);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
}();
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<GlobalSearchServerResult?> _searchOne({
|
||||
required SessionData session,
|
||||
required EmbyServer? server,
|
||||
required String keyword,
|
||||
required String types,
|
||||
required int limit,
|
||||
}) async {
|
||||
if (server == null) return null;
|
||||
final c = AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
try {
|
||||
final items = await embyGateway.getLibraryItems(
|
||||
ctx: c,
|
||||
searchTerm: keyword,
|
||||
recursive: true,
|
||||
includeItemTypes: types,
|
||||
limit: limit,
|
||||
sortBy: 'SortName',
|
||||
sortOrder: SortOrder.ascending,
|
||||
);
|
||||
if (items.isEmpty) return null;
|
||||
return GlobalSearchServerResult(
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
serverUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
items: items,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_ucTag, 'GlobalKeywordSearch on ${server.name} failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GetAggregatedResumeUseCase extends AuthedUseCase {
|
||||
GetAggregatedResumeUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Stream<GlobalSearchServerResult> executeStream({int? limitPerServer}) {
|
||||
return _fanOutPerServer(
|
||||
sessionRepository: sessionRepository,
|
||||
serverRepository: serverRepository,
|
||||
logTag: 'AggregatedResume',
|
||||
fetchItems: (context) async {
|
||||
final res = await embyGateway.getResumeItems(
|
||||
context,
|
||||
limit: limitPerServer ?? 20,
|
||||
);
|
||||
return res.Items;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetAggregatedFavoritesUseCase extends AuthedUseCase {
|
||||
static const _defaultTypes = 'Movie,Series,Episode';
|
||||
|
||||
GetAggregatedFavoritesUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Stream<GlobalSearchServerResult> executeStream({
|
||||
String includeItemTypes = _defaultTypes,
|
||||
int? limitPerServer,
|
||||
}) {
|
||||
final types = includeItemTypes.trim().isEmpty
|
||||
? _defaultTypes
|
||||
: includeItemTypes;
|
||||
return _fanOutPerServer(
|
||||
sessionRepository: sessionRepository,
|
||||
serverRepository: serverRepository,
|
||||
logTag: 'AggregatedFavorites',
|
||||
fetchItems: (context) async {
|
||||
final items = await embyGateway.getFavoriteItems(
|
||||
ctx: context,
|
||||
includeItemTypes: types,
|
||||
sortBy: 'SortName',
|
||||
sortOrder: SortOrder.ascending,
|
||||
);
|
||||
return limitPerServer == null
|
||||
? items
|
||||
: items.take(limitPerServer).toList(growable: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetAggregatedRecentUseCase extends AuthedUseCase {
|
||||
static const _defaultTypes = 'Movie,Series';
|
||||
|
||||
GetAggregatedRecentUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Stream<GlobalSearchServerResult> executeStream({
|
||||
String includeItemTypes = _defaultTypes,
|
||||
int? limitPerServer,
|
||||
}) {
|
||||
return _fanOutPerServer(
|
||||
sessionRepository: sessionRepository,
|
||||
serverRepository: serverRepository,
|
||||
logTag: 'AggregatedRecent',
|
||||
fetchItems: (context) async {
|
||||
return embyGateway.getLibraryItems(
|
||||
ctx: context,
|
||||
includeItemTypes: includeItemTypes,
|
||||
fields:
|
||||
'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,'
|
||||
'CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,'
|
||||
'CanDelete,DateCreated',
|
||||
sortBy: 'DateCreated',
|
||||
sortOrder: SortOrder.descending,
|
||||
recursive: true,
|
||||
limit: limitPerServer ?? 20,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../ports/emby_gateway.dart';
|
||||
import 'usecase_helpers.dart';
|
||||
|
||||
const _ucTag = 'UseCase';
|
||||
|
||||
class GetMediaDetailUseCase extends AuthedUseCase {
|
||||
GetMediaDetailUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<MediaDetailRes> execute(MediaDetailReq input) async {
|
||||
final base = await embyGateway.getItemDetail(await ctx(), input.itemId);
|
||||
return MediaDetailRes(base: base);
|
||||
}
|
||||
|
||||
|
||||
Future<MediaDetailRes> executeForServer({
|
||||
required String serverId,
|
||||
required String itemId,
|
||||
}) async {
|
||||
final ctx = await requireCtxForServer(serverId);
|
||||
final base = await embyGateway.getItemDetail(ctx, itemId);
|
||||
return MediaDetailRes(base: base);
|
||||
}
|
||||
}
|
||||
|
||||
class SetFavoriteUseCase extends AuthedUseCase {
|
||||
SetFavoriteUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<FavoriteSetRes> execute(FavoriteSetReq input) async {
|
||||
final result = await embyGateway.setFavoriteStatus(
|
||||
ctx: await ctx(),
|
||||
itemId: input.itemId,
|
||||
isFavorite: input.isFavorite,
|
||||
);
|
||||
return FavoriteSetRes(itemId: input.itemId, isFavorite: result);
|
||||
}
|
||||
|
||||
|
||||
Future<FavoriteSetRes> executeForServer({
|
||||
required String serverId,
|
||||
required FavoriteSetReq input,
|
||||
}) async {
|
||||
final ctx = await requireCtxForServer(serverId);
|
||||
final result = await embyGateway.setFavoriteStatus(
|
||||
ctx: ctx,
|
||||
itemId: input.itemId,
|
||||
isFavorite: input.isFavorite,
|
||||
);
|
||||
return FavoriteSetRes(itemId: input.itemId, isFavorite: result);
|
||||
}
|
||||
}
|
||||
|
||||
class SetPlayedUseCase extends AuthedUseCase {
|
||||
SetPlayedUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<PlayedSetRes> execute(PlayedSetReq input) async {
|
||||
final result = await embyGateway.setPlayedStatus(
|
||||
ctx: await ctx(),
|
||||
itemId: input.itemId,
|
||||
played: input.played,
|
||||
);
|
||||
return PlayedSetRes(itemId: input.itemId, played: result);
|
||||
}
|
||||
|
||||
|
||||
Future<PlayedSetRes> executeForServer({
|
||||
required String serverId,
|
||||
required PlayedSetReq input,
|
||||
}) async {
|
||||
final ctx = await requireCtxForServer(serverId);
|
||||
final result = await embyGateway.setPlayedStatus(
|
||||
ctx: ctx,
|
||||
itemId: input.itemId,
|
||||
played: input.played,
|
||||
);
|
||||
return PlayedSetRes(itemId: input.itemId, played: result);
|
||||
}
|
||||
}
|
||||
|
||||
class HideFromResumeUseCase extends AuthedUseCase {
|
||||
HideFromResumeUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<HideFromResumeRes> execute(HideFromResumeReq input) async {
|
||||
final result = await embyGateway.hideFromResume(
|
||||
ctx: await ctx(),
|
||||
itemId: input.itemId,
|
||||
hide: input.hide,
|
||||
);
|
||||
return HideFromResumeRes(itemId: input.itemId, hide: result);
|
||||
}
|
||||
}
|
||||
|
||||
class GetSeriesEpisodesUseCase extends AuthedUseCase {
|
||||
GetSeriesEpisodesUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<MediaEpisodesRes> execute(MediaEpisodesReq input) async {
|
||||
final items = await embyGateway.getSeriesEpisodes(
|
||||
await ctx(),
|
||||
input.seriesId,
|
||||
input.seasonId,
|
||||
);
|
||||
return MediaEpisodesRes(
|
||||
seriesId: input.seriesId,
|
||||
seasonId: input.seasonId,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<MediaEpisodesRes> executeForServer({
|
||||
required String serverId,
|
||||
required MediaEpisodesReq input,
|
||||
}) async {
|
||||
final ctx = await requireCtxForServer(serverId);
|
||||
final items = await embyGateway.getSeriesEpisodes(
|
||||
ctx,
|
||||
input.seriesId,
|
||||
input.seasonId,
|
||||
);
|
||||
return MediaEpisodesRes(
|
||||
seriesId: input.seriesId,
|
||||
seasonId: input.seasonId,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetSeriesSeasonsUseCase extends AuthedUseCase {
|
||||
GetSeriesSeasonsUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<SeriesSeasonsRes> execute(SeriesSeasonsReq input) async {
|
||||
final seasons = await embyGateway.getSeriesSeasons(
|
||||
await ctx(),
|
||||
input.seriesId,
|
||||
);
|
||||
return SeriesSeasonsRes(seriesId: input.seriesId, items: seasons);
|
||||
}
|
||||
|
||||
|
||||
Future<SeriesSeasonsRes> executeForServer({
|
||||
required String serverId,
|
||||
required SeriesSeasonsReq input,
|
||||
}) async {
|
||||
final ctx = await requireCtxForServer(serverId);
|
||||
final seasons = await embyGateway.getSeriesSeasons(ctx, input.seriesId);
|
||||
return SeriesSeasonsRes(seriesId: input.seriesId, items: seasons);
|
||||
}
|
||||
}
|
||||
|
||||
class GetSimilarItemsUseCase extends AuthedUseCase {
|
||||
GetSimilarItemsUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<SimilarItemsRes> execute(SimilarItemsReq input) async {
|
||||
final items = await embyGateway.getSimilarItems(
|
||||
await ctx(),
|
||||
input.itemId,
|
||||
limit: input.limit,
|
||||
);
|
||||
return SimilarItemsRes(itemId: input.itemId, items: items);
|
||||
}
|
||||
}
|
||||
|
||||
class GetAdditionalPartsUseCase extends AuthedUseCase {
|
||||
GetAdditionalPartsUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<AdditionalPartsRes> execute(AdditionalPartsReq input) async {
|
||||
final items = await embyGateway.getAdditionalParts(
|
||||
await ctx(),
|
||||
input.itemId,
|
||||
);
|
||||
return AdditionalPartsRes(itemId: input.itemId, items: items);
|
||||
}
|
||||
|
||||
|
||||
Future<AdditionalPartsRes> executeForServer({
|
||||
required String serverId,
|
||||
required String itemId,
|
||||
}) async {
|
||||
final ctx = await resolveAuthedContextForServer(
|
||||
sessionRepository: sessionRepository,
|
||||
serverRepository: serverRepository,
|
||||
serverId: serverId,
|
||||
);
|
||||
if (ctx == null) {
|
||||
return AdditionalPartsRes(itemId: itemId, items: const []);
|
||||
}
|
||||
final items = await embyGateway.getAdditionalParts(ctx, itemId);
|
||||
return AdditionalPartsRes(itemId: itemId, items: items);
|
||||
}
|
||||
}
|
||||
|
||||
class GetSpecialFeaturesUseCase extends AuthedUseCase {
|
||||
GetSpecialFeaturesUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
Future<List<EmbyRawItem>> execute(String itemId) async {
|
||||
return embyGateway.getSpecialFeatures(await ctx(), itemId);
|
||||
}
|
||||
}
|
||||
|
||||
class CrossServerSearchUseCase extends AuthedUseCase {
|
||||
CrossServerSearchUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
});
|
||||
|
||||
|
||||
final Map<String, _SeriesIdentity?> _seriesIdentityCache = {};
|
||||
final Map<String, _EmptyServerSearchStreak> _emptyServerSearchStreaks = {};
|
||||
|
||||
static const _emptyServerSkipThreshold = 3;
|
||||
static const _emptyServerSkipTtl = Duration(hours: 1);
|
||||
|
||||
String _identityCacheKey(String serverId, String seriesPidQuery) =>
|
||||
'$serverId|$seriesPidQuery';
|
||||
|
||||
|
||||
Stream<List<CrossServerSourceCard>> executeStream(
|
||||
CrossServerSearchReq input, {
|
||||
CancellationToken? cancelToken,
|
||||
}) {
|
||||
final controller = StreamController<List<CrossServerSourceCard>>();
|
||||
final sw = Stopwatch()..start();
|
||||
() async {
|
||||
try {
|
||||
final activeServerId = await sessionRepository.getActiveServerId();
|
||||
final excludedServerId = input.excludedServerId ?? activeServerId;
|
||||
final allSessions = await sessionRepository.listAll();
|
||||
final allServers = await serverRepository.list();
|
||||
final serverMap = {for (final s in allServers) s.id: s};
|
||||
|
||||
final acc = <CrossServerSourceCard>[];
|
||||
var probed = 0;
|
||||
var skipped = 0;
|
||||
final futures = <Future<void>>[];
|
||||
for (final session in allSessions) {
|
||||
if (session.serverId == excludedServerId) continue;
|
||||
final server = serverMap[session.serverId];
|
||||
if (server == null) continue;
|
||||
if (_shouldSkipEmptyServer(server)) {
|
||||
skipped++;
|
||||
final streak = _emptyServerSearchStreaks[server.id];
|
||||
AppLogger.debug(
|
||||
'DetailTiming',
|
||||
'xserver.server name=${server.name} skipped emptyStreak=${streak?.count ?? 0}',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
probed++;
|
||||
futures.add(() async {
|
||||
final serverSw = Stopwatch()..start();
|
||||
final cards = await _buildCardsForServer(
|
||||
server,
|
||||
session,
|
||||
input,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
AppLogger.debug(
|
||||
'DetailTiming',
|
||||
'xserver.server name=${server.name} cards=${cards.length} ms=${serverSw.elapsedMilliseconds}',
|
||||
);
|
||||
if (cancelToken?.isCancelled == true) return;
|
||||
_recordServerSearchResult(server, cards);
|
||||
if (cards.isEmpty) return;
|
||||
acc.addAll(cards);
|
||||
if (!controller.isClosed) {
|
||||
controller.add(List.unmodifiable(acc));
|
||||
}
|
||||
}());
|
||||
}
|
||||
await Future.wait(futures);
|
||||
AppLogger.debug(
|
||||
'DetailTiming',
|
||||
'xserver.total probed=$probed skipped=$skipped cards=${acc.length} ms=${sw.elapsedMilliseconds}',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!controller.isClosed) controller.addError(e);
|
||||
} finally {
|
||||
if (!controller.isClosed) await controller.close();
|
||||
}
|
||||
}();
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
bool _shouldSkipEmptyServer(EmbyServer server) {
|
||||
final streak = _emptyServerSearchStreaks[server.id];
|
||||
final skipUntil = streak?.skipUntil;
|
||||
if (skipUntil == null) return false;
|
||||
if (DateTime.now().isBefore(skipUntil)) return true;
|
||||
_emptyServerSearchStreaks.remove(server.id);
|
||||
return false;
|
||||
}
|
||||
|
||||
void _recordServerSearchResult(
|
||||
EmbyServer server,
|
||||
List<CrossServerSourceCard> cards,
|
||||
) {
|
||||
if (cards.isNotEmpty) {
|
||||
_emptyServerSearchStreaks.remove(server.id);
|
||||
return;
|
||||
}
|
||||
final streak = _emptyServerSearchStreaks.putIfAbsent(
|
||||
server.id,
|
||||
_EmptyServerSearchStreak.new,
|
||||
);
|
||||
streak.count++;
|
||||
if (streak.count < _emptyServerSkipThreshold) return;
|
||||
streak.skipUntil = DateTime.now().add(_emptyServerSkipTtl);
|
||||
AppLogger.debug(
|
||||
'DetailTiming',
|
||||
'xserver.server name=${server.name} emptyStreak=${streak.count} '
|
||||
'skipUntil=${streak.skipUntil!.toIso8601String()}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<CrossServerSourceCard>> _buildCardsForServer(
|
||||
EmbyServer server,
|
||||
SessionData session,
|
||||
CrossServerSearchReq req, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final ctx = AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
try {
|
||||
if (req.anchorType == 'Movie') {
|
||||
return await _buildMovieCards(
|
||||
ctx,
|
||||
server,
|
||||
session,
|
||||
req,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
if (req.anchorType == 'Episode') {
|
||||
return await _buildEpisodeCards(
|
||||
ctx,
|
||||
server,
|
||||
session,
|
||||
req,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
return const [];
|
||||
} on CancelledException {
|
||||
return const [];
|
||||
} catch (e) {
|
||||
AppLogger.warn(_ucTag, 'CrossServerSearch on ${server.name} failed', e);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CrossServerSourceCard>> _buildMovieCards(
|
||||
AuthedRequestContext ctx,
|
||||
EmbyServer server,
|
||||
SessionData session,
|
||||
CrossServerSearchReq req, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
const movieFields = 'ProviderIds,UserData,MediaSources,MediaStreams';
|
||||
final providerQuery = _buildProviderIdQuery(req.providerIds);
|
||||
var items = <EmbyRawItem>[];
|
||||
if (providerQuery != null && providerQuery.isNotEmpty) {
|
||||
final providerItems = await embyGateway.getLibraryItems(
|
||||
ctx: ctx,
|
||||
includeItemTypes: 'Movie',
|
||||
anyProviderIdEquals: providerQuery,
|
||||
fields: movieFields,
|
||||
limit: 10,
|
||||
recursive: true,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
items = providerItems
|
||||
.where((item) => _itemMatchesAnyProviderId(item, req.providerIds))
|
||||
.toList(growable: false);
|
||||
final droppedItemCount = providerItems.length - items.length;
|
||||
if (droppedItemCount > 0) {
|
||||
AppLogger.debug(
|
||||
'DetailTiming',
|
||||
'xserver.provider-filter server=${server.name} '
|
||||
'dropped=$droppedItemCount returned=${providerItems.length}',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (items.isEmpty && req.itemName.isNotEmpty) {
|
||||
final nameSearchItems = await embyGateway.getLibraryItems(
|
||||
ctx: ctx,
|
||||
includeItemTypes: 'Movie',
|
||||
searchTerm: req.itemName,
|
||||
fields: movieFields,
|
||||
limit: 20,
|
||||
recursive: true,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
items = nameSearchItems
|
||||
.where(
|
||||
(item) => !_itemHasConflictingProviderId(item, req.providerIds),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
final cards = <CrossServerSourceCard>[];
|
||||
if (cancelToken?.isCancelled == true) return List.unmodifiable(cards);
|
||||
final detailedList = await Future.wait(
|
||||
items.map(
|
||||
(it) => _fetchDetailedItem(ctx, server, it, cancelToken: cancelToken),
|
||||
),
|
||||
);
|
||||
if (cancelToken?.isCancelled == true) return List.unmodifiable(cards);
|
||||
for (final detailed in detailedList) {
|
||||
cards.addAll(_expandToCards(server, session, detailed));
|
||||
}
|
||||
return List.unmodifiable(cards);
|
||||
}
|
||||
|
||||
|
||||
Future<EmbyRawItem> _fetchDetailedItem(
|
||||
AuthedRequestContext ctx,
|
||||
EmbyServer server,
|
||||
EmbyRawItem item, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
if (cancelToken?.isCancelled == true) return item;
|
||||
try {
|
||||
final full = await embyGateway.getItemDetail(ctx, item.Id);
|
||||
final fullSources = full.extra['MediaSources'];
|
||||
if (fullSources is List && fullSources.isNotEmpty) return full;
|
||||
return item;
|
||||
} catch (e) {
|
||||
AppLogger.warn(
|
||||
_ucTag,
|
||||
'CrossServer detail fetch failed on ${server.name} item=${item.Id}',
|
||||
e,
|
||||
);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CrossServerSourceCard>> _buildEpisodeCards(
|
||||
AuthedRequestContext ctx,
|
||||
EmbyServer server,
|
||||
SessionData session,
|
||||
CrossServerSearchReq req, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
const episodeFields = 'ProviderIds,UserData,MediaSources,MediaStreams';
|
||||
|
||||
final providerQuery = _buildProviderIdQuery(req.providerIds);
|
||||
if (providerQuery != null && providerQuery.isNotEmpty) {
|
||||
final episodes = await embyGateway.getLibraryItems(
|
||||
ctx: ctx,
|
||||
includeItemTypes: 'Episode',
|
||||
anyProviderIdEquals: providerQuery,
|
||||
fields: episodeFields,
|
||||
limit: 5,
|
||||
recursive: true,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
final exact = _matchEpisodeBySE(
|
||||
episodes,
|
||||
req.parentIndexNumber,
|
||||
req.indexNumber,
|
||||
);
|
||||
if (exact != null) {
|
||||
final detailed = await _fetchDetailedItem(
|
||||
ctx,
|
||||
server,
|
||||
exact,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
return _expandToCards(server, session, detailed);
|
||||
}
|
||||
}
|
||||
|
||||
final seriesProviderIds = req.seriesProviderIds;
|
||||
if (seriesProviderIds == null || seriesProviderIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final identity = await _resolveSeriesIdentity(
|
||||
ctx,
|
||||
server.id,
|
||||
seriesProviderIds,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
if (identity == null) return const [];
|
||||
|
||||
EmbyRawSeason? matchedSeason;
|
||||
for (final s in identity.seasons) {
|
||||
if (s.IndexNumber == req.parentIndexNumber) {
|
||||
matchedSeason = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedSeason == null) return const [];
|
||||
|
||||
final episodes = await embyGateway.getSeriesEpisodes(
|
||||
ctx,
|
||||
identity.remoteSeriesId,
|
||||
matchedSeason.Id,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
final target = _matchEpisodeBySE(
|
||||
episodes,
|
||||
req.parentIndexNumber,
|
||||
req.indexNumber,
|
||||
);
|
||||
if (target == null) return const [];
|
||||
final detailed = await _fetchDetailedItem(
|
||||
ctx,
|
||||
server,
|
||||
target,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
return _expandToCards(server, session, detailed);
|
||||
}
|
||||
|
||||
|
||||
Future<_SeriesIdentity?> _resolveSeriesIdentity(
|
||||
AuthedRequestContext ctx,
|
||||
String serverId,
|
||||
Map<String, String> seriesProviderIds, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final seriesPidQuery = _buildProviderIdQuery(seriesProviderIds);
|
||||
if (seriesPidQuery == null || seriesPidQuery.isEmpty) return null;
|
||||
final key = _identityCacheKey(serverId, seriesPidQuery);
|
||||
if (_seriesIdentityCache.containsKey(key)) {
|
||||
return _seriesIdentityCache[key];
|
||||
}
|
||||
final seriesList = await embyGateway.getLibraryItems(
|
||||
ctx: ctx,
|
||||
includeItemTypes: 'Series',
|
||||
anyProviderIdEquals: seriesPidQuery,
|
||||
fields: 'ProviderIds',
|
||||
limit: 1,
|
||||
recursive: true,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
if (seriesList.isEmpty ||
|
||||
!_itemMatchesAnyProviderId(seriesList.first, seriesProviderIds)) {
|
||||
_seriesIdentityCache[key] = null;
|
||||
return null;
|
||||
}
|
||||
final remoteSeriesId = seriesList.first.Id;
|
||||
final seasons = await embyGateway.getSeriesSeasons(
|
||||
ctx,
|
||||
remoteSeriesId,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
final identity = _SeriesIdentity(
|
||||
remoteSeriesId: remoteSeriesId,
|
||||
seasons: seasons,
|
||||
);
|
||||
_seriesIdentityCache[key] = identity;
|
||||
return identity;
|
||||
}
|
||||
|
||||
|
||||
Future<void> prewarmSeriesIdentity(
|
||||
Map<String, String> seriesProviderIds, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
if (seriesProviderIds.isEmpty) return;
|
||||
|
||||
try {
|
||||
final activeServerId = await sessionRepository.getActiveServerId();
|
||||
final allSessions = await sessionRepository.listAll();
|
||||
final allServers = await serverRepository.list();
|
||||
final serverMap = {for (final s in allServers) s.id: s};
|
||||
|
||||
final futures = <Future<void>>[];
|
||||
for (final session in allSessions) {
|
||||
if (session.serverId == activeServerId) continue;
|
||||
final server = serverMap[session.serverId];
|
||||
if (server == null) continue;
|
||||
final ctx = AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
futures.add(() async {
|
||||
try {
|
||||
await _resolveSeriesIdentity(
|
||||
ctx,
|
||||
server.id,
|
||||
seriesProviderIds,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on CancelledException {
|
||||
return;
|
||||
} catch (e) {
|
||||
AppLogger.warn(
|
||||
_ucTag,
|
||||
'prewarmSeriesIdentity on ${server.name} failed',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}());
|
||||
}
|
||||
await Future.wait(futures);
|
||||
} on CancelledException {
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.warn(_ucTag, 'prewarmSeriesIdentity setup failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
static List<CrossServerSourceCard> _expandToCards(
|
||||
EmbyServer server,
|
||||
SessionData session,
|
||||
EmbyRawItem item,
|
||||
) {
|
||||
final raw = item.extra['MediaSources'];
|
||||
if (raw is! List) return const [];
|
||||
final sources = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(EmbyRawMediaSource.fromJson)
|
||||
.toList();
|
||||
final cards = <CrossServerSourceCard>[];
|
||||
for (final s in sources) {
|
||||
final id = s.Id;
|
||||
if (id == null) continue;
|
||||
cards.add(
|
||||
CrossServerSourceCard(
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
serverUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
landingItemId: item.Id,
|
||||
mediaSourceId: id,
|
||||
item: item,
|
||||
mediaSource: s,
|
||||
quality: _extractQualityFromSource(s),
|
||||
),
|
||||
);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
static EmbyRawItem? _matchEpisodeBySE(
|
||||
List<EmbyRawItem> episodes,
|
||||
int? parentIndex,
|
||||
int? index,
|
||||
) {
|
||||
if (parentIndex == null || index == null) return null;
|
||||
for (final it in episodes) {
|
||||
final ps = (it.extra['ParentIndexNumber'] as num?)?.toInt();
|
||||
if (ps == parentIndex && it.IndexNumber == index) return it;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _itemMatchesAnyProviderId(
|
||||
EmbyRawItem item,
|
||||
Map<String, String> requestedProviderIds,
|
||||
) {
|
||||
final normalizedRequestedProviderIds = normalizeProviderIds(
|
||||
requestedProviderIds,
|
||||
);
|
||||
if (normalizedRequestedProviderIds.isEmpty) return false;
|
||||
|
||||
final normalizedItemProviderIds = normalizedProviderIdsOfItem(item);
|
||||
for (final requestedEntry in normalizedRequestedProviderIds.entries) {
|
||||
if (normalizedItemProviderIds[requestedEntry.key] ==
|
||||
requestedEntry.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _itemHasConflictingProviderId(
|
||||
EmbyRawItem item,
|
||||
Map<String, String> requestedProviderIds,
|
||||
) {
|
||||
final normalizedRequestedProviderIds = normalizeProviderIds(
|
||||
requestedProviderIds,
|
||||
);
|
||||
if (normalizedRequestedProviderIds.isEmpty) return false;
|
||||
|
||||
final normalizedItemProviderIds = normalizedProviderIdsOfItem(item);
|
||||
for (final requestedEntry in normalizedRequestedProviderIds.entries) {
|
||||
final itemProviderId = normalizedItemProviderIds[requestedEntry.key];
|
||||
if (itemProviderId != null && itemProviderId != requestedEntry.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static MediaQualityInfo? _extractQualityFromSource(EmbyRawMediaSource src) {
|
||||
final streams = src.MediaStreams ?? const [];
|
||||
if (streams.isEmpty || !streams.any((s) => s.Type == 'Video')) {
|
||||
final fallback = MediaQualityInfo.fromSourceName(
|
||||
src.Name,
|
||||
fallbackHeight: src.Height,
|
||||
container: src.Container,
|
||||
);
|
||||
AppLogger.debug(
|
||||
'DetailQuality',
|
||||
'name-fallback sourceId=${src.Id ?? 'null'} '
|
||||
'name="${src.Name ?? 'null'}" -> '
|
||||
'${fallback == null ? 'null' : '"${fallback.resolutionLabel} ${fallback.dynamicRangeLabel}"'}',
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return MediaQualityInfo.fromMediaSource(src);
|
||||
}
|
||||
|
||||
static String? _buildProviderIdQuery(Map<String, String> providerIds) {
|
||||
if (providerIds.isEmpty) return null;
|
||||
final parts = <String>[];
|
||||
for (final entry in providerIds.entries) {
|
||||
if (entry.value.isNotEmpty) {
|
||||
parts.add('${entry.key}.${entry.value}');
|
||||
}
|
||||
}
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
class _SeriesIdentity {
|
||||
final String remoteSeriesId;
|
||||
final List<EmbyRawSeason> seasons;
|
||||
const _SeriesIdentity({required this.remoteSeriesId, required this.seasons});
|
||||
}
|
||||
|
||||
class _EmptyServerSearchStreak {
|
||||
int count = 0;
|
||||
DateTime? skipUntil;
|
||||
}
|
||||
|
||||
|
||||
String _buildAvailabilityProviderQuery({
|
||||
required String tmdbId,
|
||||
String? imdbId,
|
||||
}) {
|
||||
final parts = <String>['Tmdb.${tmdbId.trim()}'];
|
||||
final imdb = imdbId?.trim() ?? '';
|
||||
if (imdb.isNotEmpty) parts.add('Imdb.$imdb');
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
|
||||
class TmdbAvailabilityUseCase extends AuthedUseCase {
|
||||
TmdbAvailabilityUseCase({
|
||||
required super.sessionRepository,
|
||||
required super.serverRepository,
|
||||
required super.embyGateway,
|
||||
this.settleTimeout = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
final Duration settleTimeout;
|
||||
|
||||
Stream<List<TmdbLibraryHit>> executeStream(
|
||||
TmdbAvailabilityReq input, {
|
||||
CancellationToken? cancelToken,
|
||||
}) {
|
||||
final controller = StreamController<List<TmdbLibraryHit>>();
|
||||
Timer? settleTimer;
|
||||
() async {
|
||||
try {
|
||||
final tmdbId = input.tmdbId.trim();
|
||||
if (tmdbId.isEmpty) {
|
||||
if (!controller.isClosed) controller.add(const []);
|
||||
return;
|
||||
}
|
||||
final isMovie = input.mediaType == 'movie';
|
||||
final includeItemTypes = isMovie ? 'Movie' : 'Series';
|
||||
final providerQuery = _buildAvailabilityProviderQuery(
|
||||
tmdbId: tmdbId,
|
||||
imdbId: isMovie ? input.imdbId : null,
|
||||
);
|
||||
|
||||
final allSessions = await sessionRepository.listAll();
|
||||
final allServers = await serverRepository.list();
|
||||
final serverMap = {for (final s in allServers) s.id: s};
|
||||
|
||||
final acc = <TmdbLibraryHit>[];
|
||||
var settled = false;
|
||||
void emit() {
|
||||
if (!controller.isClosed) controller.add(List.unmodifiable(acc));
|
||||
}
|
||||
|
||||
var pending = 0;
|
||||
final futures = <Future<void>>[];
|
||||
for (final session in allSessions) {
|
||||
final server = serverMap[session.serverId];
|
||||
if (server == null) continue;
|
||||
pending++;
|
||||
futures.add(
|
||||
_probeServer(
|
||||
server,
|
||||
session,
|
||||
includeItemTypes,
|
||||
providerQuery,
|
||||
cancelToken: cancelToken,
|
||||
).then((hit) {
|
||||
pending--;
|
||||
if (hit != null) acc.add(hit);
|
||||
if (pending == 0) {
|
||||
settleTimer?.cancel();
|
||||
if (!settled || hit != null) emit();
|
||||
} else if (settled && hit != null) {
|
||||
emit();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (pending == 0) {
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
settleTimer = Timer(settleTimeout, () {
|
||||
settled = true;
|
||||
emit();
|
||||
});
|
||||
await Future.wait(futures);
|
||||
} catch (e) {
|
||||
if (!controller.isClosed) controller.addError(e);
|
||||
} finally {
|
||||
settleTimer?.cancel();
|
||||
if (!controller.isClosed) await controller.close();
|
||||
}
|
||||
}();
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<TmdbLibraryHit?> _probeServer(
|
||||
EmbyServer server,
|
||||
SessionData session,
|
||||
String includeItemTypes,
|
||||
String providerQuery, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final ctx = AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
try {
|
||||
final items = await embyGateway.getLibraryItems(
|
||||
ctx: ctx,
|
||||
includeItemTypes: includeItemTypes,
|
||||
anyProviderIdEquals: providerQuery,
|
||||
fields: 'ProviderIds,ProductionYear,UserData',
|
||||
limit: 1,
|
||||
recursive: true,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
if (items.isEmpty) return null;
|
||||
final item = items.first;
|
||||
return TmdbLibraryHit(
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
itemId: item.Id,
|
||||
itemType: item.Type ?? includeItemTypes,
|
||||
name: item.Name,
|
||||
productionYear: item.ProductionYear,
|
||||
playbackPositionTicks: item.UserData?.PlaybackPositionTicks,
|
||||
runTimeTicks: item.RunTimeTicks,
|
||||
);
|
||||
} on CancelledException {
|
||||
return null;
|
||||
} catch (e) {
|
||||
AppLogger.warn(_ucTag, 'TmdbAvailability on ${server.name} failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../contracts/danmaku.dart';
|
||||
import '../../../contracts/error.dart';
|
||||
import '../../../contracts/script_widget.dart';
|
||||
import '../../../../shared/utils/app_logger.dart';
|
||||
import '../../../../shared/utils/async_lock.dart';
|
||||
import '../../errors.dart';
|
||||
import '../../ports/danmaku_sources_store.dart';
|
||||
import '../../ports/script_widget_remote_gateway.dart';
|
||||
import '../../ports/script_widget_repository.dart';
|
||||
import '../../ports/script_widget_runtime.dart';
|
||||
|
||||
const String scriptWidgetHostVersion = '0.0.2';
|
||||
|
||||
class ScriptWidgetContentSection {
|
||||
final String title;
|
||||
final List<ScriptVideoItem> items;
|
||||
|
||||
const ScriptWidgetContentSection({required this.title, required this.items});
|
||||
}
|
||||
|
||||
class ScriptWidgetService {
|
||||
static const String _tag = 'ScriptWidgetService';
|
||||
|
||||
final ScriptWidgetRepository repository;
|
||||
final ScriptWidgetRemoteGateway remoteGateway;
|
||||
final ScriptWidgetRuntime runtime;
|
||||
final DanmakuSourcesStore? danmakuSourcesStore;
|
||||
final Future<void> Function()? onDanmakuSourcesChanged;
|
||||
final Map<String, AsyncLock> _widgetLocks = <String, AsyncLock>{};
|
||||
|
||||
ScriptWidgetService({
|
||||
required this.repository,
|
||||
required this.remoteGateway,
|
||||
required this.runtime,
|
||||
this.danmakuSourcesStore,
|
||||
this.onDanmakuSourcesChanged,
|
||||
});
|
||||
|
||||
Future<List<InstalledScriptWidget>> listWidgets() => repository.listWidgets();
|
||||
|
||||
Future<List<ScriptWidgetSubscription>> listSubscriptions() =>
|
||||
repository.listSubscriptions();
|
||||
|
||||
Future<InstalledScriptWidget> requireWidget(String widgetId) async {
|
||||
final widget = await repository.findWidget(widgetId);
|
||||
if (widget == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '未找到模块:$widgetId');
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> installFromUrl(String url) async {
|
||||
final scriptSource = await remoteGateway.fetchText(url);
|
||||
return installFromSource(scriptSource, sourceUrl: url);
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> installFromSource(
|
||||
String scriptSource, {
|
||||
String sourceUrl = '',
|
||||
}) async {
|
||||
final manifest = await runtime.inspectWidget(scriptSource);
|
||||
_assertCompatible(manifest);
|
||||
return _runWidgetOperation(
|
||||
manifest.id,
|
||||
() =>
|
||||
_installInspectedWidget(manifest, scriptSource, sourceUrl: sourceUrl),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ScriptWidgetSubscription> importSubscription(String url) async {
|
||||
final rawCatalog = await remoteGateway.fetchText(url);
|
||||
return importSubscriptionSource(rawCatalog, sourceId: url);
|
||||
}
|
||||
|
||||
Future<ScriptWidgetSubscription> importSubscriptionSource(
|
||||
String rawCatalog, {
|
||||
required String sourceId,
|
||||
}) async {
|
||||
final decoded = jsonDecode(rawCatalog);
|
||||
if (decoded is! Map) {
|
||||
throw DomainError(ErrorCode.invalidParams, '订阅文件不是有效的 JSON 对象');
|
||||
}
|
||||
final decodedCatalog = ScriptWidgetCatalog.fromJson(
|
||||
Map<String, dynamic>.from(decoded),
|
||||
);
|
||||
if (decodedCatalog.title.trim().isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '订阅缺少 title');
|
||||
}
|
||||
final catalog = decodedCatalog.copyWith(
|
||||
widgets: decodedCatalog.widgets
|
||||
.map((entry) => _resolveCatalogEntryUrl(entry, sourceId))
|
||||
.toList(growable: false),
|
||||
);
|
||||
final subscription = ScriptWidgetSubscription(
|
||||
url: sourceId,
|
||||
catalog: catalog,
|
||||
refreshedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await repository.upsertSubscription(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> refreshWidget(String widgetId) async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
if (widget.sourceUrl.trim().isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '本地导入模块没有可用的更新地址');
|
||||
}
|
||||
final scriptSource = await remoteGateway.fetchText(widget.sourceUrl);
|
||||
final manifest = await runtime.inspectWidget(scriptSource);
|
||||
if (manifest.id != widgetId) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块更新的 ID 不匹配:期望 $widgetId,实际为 ${manifest.id}',
|
||||
);
|
||||
}
|
||||
_assertCompatible(manifest);
|
||||
return _runWidgetOperation(
|
||||
widgetId,
|
||||
() => _installInspectedWidget(
|
||||
manifest,
|
||||
scriptSource,
|
||||
sourceUrl: widget.sourceUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setWidgetEnabled(String widgetId, bool enabled) async {
|
||||
await _runWidgetOperation(widgetId, () async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
final updated = widget.copyWith(
|
||||
enabled: enabled,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await repository.upsertWidget(updated);
|
||||
await _synchronizeDanmakuSource(updated);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateGlobalParams(
|
||||
String widgetId,
|
||||
Map<String, Object?> globalParams,
|
||||
) async {
|
||||
await _runWidgetOperation(widgetId, () async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
await repository.upsertWidget(
|
||||
widget.copyWith(
|
||||
globalParams: Map<String, Object?>.unmodifiable(globalParams),
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteWidget(String widgetId) async {
|
||||
await _runWidgetOperation(widgetId, () async {
|
||||
await repository.deleteWidget(widgetId);
|
||||
await runtime.unloadWidget(widgetId);
|
||||
final sourceStore = danmakuSourcesStore;
|
||||
if (sourceStore != null) {
|
||||
final sources = await sourceStore.list();
|
||||
await sourceStore.replaceAll(
|
||||
sources.where((source) => source.url != 'jsw://$widgetId').toList(),
|
||||
);
|
||||
await onDanmakuSourcesChanged?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Object?> invoke(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
return _runWidgetOperation(widgetId, () async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
if (!widget.enabled) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块已停用:${widget.manifest.title}',
|
||||
);
|
||||
}
|
||||
await _ensureLoaded(widget);
|
||||
final rawResult = await runtime.invokeModuleFunction(
|
||||
widgetId,
|
||||
functionName,
|
||||
<String, Object?>{...widget.globalParams, ...params},
|
||||
);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'[$widgetId] $functionName -> ${_summarizeInvocationResult(rawResult)}',
|
||||
);
|
||||
return rawResult;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<ScriptWidgetContentSection>> loadModuleSections(
|
||||
String widgetId,
|
||||
ScriptWidgetModule module,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
final resolvedParams = <String, Object?>{
|
||||
...buildScriptWidgetParameterDefaults(module.params),
|
||||
...params,
|
||||
};
|
||||
final rawResult = await invoke(
|
||||
widgetId,
|
||||
module.functionName,
|
||||
resolvedParams,
|
||||
);
|
||||
return decodeVideoSections(
|
||||
rawResult,
|
||||
fallbackTitle: module.title,
|
||||
preserveSections: module.sectionMode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ScriptVideoItem>> search(
|
||||
String widgetId,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
final searchModule = widget.manifest.search;
|
||||
if (searchModule == null) return const <ScriptVideoItem>[];
|
||||
final rawResult = await invoke(widgetId, searchModule.functionName, params);
|
||||
return decodeVideoItems(rawResult);
|
||||
}
|
||||
|
||||
Future<ScriptVideoItem?> loadDetail(String widgetId, String link) async {
|
||||
final rawResult = await invoke(widgetId, 'loadDetail', <String, Object?>{
|
||||
'__forwardRawArgument': link,
|
||||
'link': link,
|
||||
});
|
||||
final items = decodeVideoItems(rawResult);
|
||||
return items.isEmpty ? null : items.first;
|
||||
}
|
||||
|
||||
Future<List<ScriptVideoResource>> loadResources(
|
||||
String widgetId,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
final resourceModule = widget.manifest.modules
|
||||
.where(
|
||||
(module) => module.id == 'loadResource' || module.type == 'stream',
|
||||
)
|
||||
.firstOrNull;
|
||||
if (resourceModule == null) return const <ScriptVideoResource>[];
|
||||
final rawResult = await invoke(
|
||||
widgetId,
|
||||
resourceModule.functionName,
|
||||
params,
|
||||
);
|
||||
return _decodeMapList(rawResult)
|
||||
.map(ScriptVideoResource.fromJson)
|
||||
.where((resource) => resource.url.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _ensureLoaded(InstalledScriptWidget widget) async {
|
||||
final widgetId = widget.manifest.id;
|
||||
if (runtime.isWidgetLoaded(widgetId)) return;
|
||||
final loadedManifest = await runtime.loadWidget(widget.scriptSource);
|
||||
if (loadedManifest.id != widgetId) {
|
||||
await runtime.unloadWidget(loadedManifest.id);
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'已安装模块的脚本 ID 不匹配:期望 $widgetId,实际为 ${loadedManifest.id}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> _installInspectedWidget(
|
||||
ScriptWidgetManifest manifest,
|
||||
String scriptSource, {
|
||||
required String sourceUrl,
|
||||
}) async {
|
||||
final existingWidget = await repository.findWidget(manifest.id);
|
||||
final existingDanmakuSources = danmakuSourcesStore == null
|
||||
? null
|
||||
: await danmakuSourcesStore!.list();
|
||||
final now = DateTime.now().toUtc();
|
||||
final globalParams = <String, Object?>{
|
||||
for (final parameter in manifest.globalParams)
|
||||
parameter.name:
|
||||
existingWidget?.globalParams[parameter.name] ?? parameter.value,
|
||||
};
|
||||
final installedWidget = InstalledScriptWidget(
|
||||
manifest: manifest,
|
||||
scriptSource: scriptSource,
|
||||
sourceUrl: sourceUrl,
|
||||
enabled: existingWidget?.enabled ?? true,
|
||||
globalParams: globalParams,
|
||||
installedAt: existingWidget?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
try {
|
||||
final loadedManifest = await runtime.loadWidget(scriptSource);
|
||||
if (loadedManifest.id != manifest.id) {
|
||||
await runtime.unloadWidget(loadedManifest.id);
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块脚本在检查与加载期间返回了不同的 ID',
|
||||
details: <String, Object?>{
|
||||
'inspectedId': manifest.id,
|
||||
'loadedId': loadedManifest.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
await repository.upsertWidget(installedWidget);
|
||||
await _synchronizeDanmakuSource(installedWidget);
|
||||
return installedWidget;
|
||||
} catch (error, stackTrace) {
|
||||
await _restoreInstallation(
|
||||
manifest.id,
|
||||
existingWidget,
|
||||
existingDanmakuSources,
|
||||
);
|
||||
Error.throwWithStackTrace(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restoreInstallation(
|
||||
String widgetId,
|
||||
InstalledScriptWidget? existingWidget,
|
||||
List<DanmakuSource>? existingDanmakuSources,
|
||||
) async {
|
||||
await _ignoreRollbackFailure(() async {
|
||||
if (existingWidget == null) {
|
||||
await repository.deleteWidget(widgetId);
|
||||
} else {
|
||||
await repository.upsertWidget(existingWidget);
|
||||
}
|
||||
});
|
||||
await _ignoreRollbackFailure(() async {
|
||||
if (existingWidget == null) {
|
||||
await runtime.unloadWidget(widgetId);
|
||||
} else {
|
||||
var restoredExistingRuntime = false;
|
||||
try {
|
||||
final restoredManifest = await runtime.loadWidget(
|
||||
existingWidget.scriptSource,
|
||||
);
|
||||
restoredExistingRuntime = restoredManifest.id == widgetId;
|
||||
if (!restoredExistingRuntime) {
|
||||
await runtime.unloadWidget(restoredManifest.id);
|
||||
}
|
||||
} finally {
|
||||
if (!restoredExistingRuntime) {
|
||||
await runtime.unloadWidget(widgetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
final sourceStore = danmakuSourcesStore;
|
||||
if (sourceStore != null && existingDanmakuSources != null) {
|
||||
await _ignoreRollbackFailure(() async {
|
||||
await sourceStore.replaceAll(existingDanmakuSources);
|
||||
await onDanmakuSourcesChanged?.call();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _runWidgetOperation<T>(
|
||||
String widgetId,
|
||||
Future<T> Function() operation,
|
||||
) {
|
||||
|
||||
|
||||
return _widgetLocks.putIfAbsent(widgetId, AsyncLock.new).run(operation);
|
||||
}
|
||||
|
||||
Future<void> _ignoreRollbackFailure(Future<void> Function() rollback) async {
|
||||
try {
|
||||
await rollback();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
ScriptWidgetCatalogEntry _resolveCatalogEntryUrl(
|
||||
ScriptWidgetCatalogEntry entry,
|
||||
String sourceId,
|
||||
) {
|
||||
final entryUri = Uri.tryParse(entry.url);
|
||||
if (entryUri == null || entryUri.hasScheme) return entry;
|
||||
final sourceUri = Uri.tryParse(sourceId);
|
||||
if (sourceUri == null || !sourceUri.hasScheme) return entry;
|
||||
return entry.copyWith(url: sourceUri.resolveUri(entryUri).toString());
|
||||
}
|
||||
|
||||
void _assertCompatible(ScriptWidgetManifest manifest) {
|
||||
if (_compareVersions(manifest.requiredVersion, scriptWidgetHostVersion) >
|
||||
0) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块需要 ForwardWidgets ${manifest.requiredVersion},当前兼容版本为 $scriptWidgetHostVersion',
|
||||
);
|
||||
}
|
||||
if (manifest.modules.any((module) => module.requiresWebView)) {
|
||||
throw DomainError(ErrorCode.invalidParams, '当前版本暂不支持 requiresWebView 模块');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _synchronizeDanmakuSource(InstalledScriptWidget widget) async {
|
||||
final sourceStore = danmakuSourcesStore;
|
||||
if (sourceStore == null) return;
|
||||
final hasDanmaku = widget.manifest.modules.any(
|
||||
(module) => module.type == 'danmu',
|
||||
);
|
||||
final sourceUrl = 'jsw://${widget.manifest.id}';
|
||||
final sources = await sourceStore.list();
|
||||
sources.removeWhere((source) => source.url == sourceUrl);
|
||||
if (hasDanmaku) {
|
||||
sources.add(
|
||||
DanmakuSource(
|
||||
id: 'script-widget-${widget.manifest.id}',
|
||||
name: widget.manifest.title,
|
||||
url: sourceUrl,
|
||||
enabled: widget.enabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
await sourceStore.replaceAll(sources);
|
||||
await onDanmakuSourcesChanged?.call();
|
||||
}
|
||||
}
|
||||
|
||||
List<ScriptVideoItem> decodeVideoItems(Object? rawResult) {
|
||||
final maps = _decodeMapList(rawResult);
|
||||
if (maps.isEmpty) {
|
||||
if (_isExplicitlyEmptyModuleResult(rawResult)) {
|
||||
return const <ScriptVideoItem>[];
|
||||
}
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块返回了无法识别的内容格式',
|
||||
details: <String, Object?>{
|
||||
'resultType': rawResult.runtimeType.toString(),
|
||||
'result': _summarizeModuleResult(rawResult),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final items = <ScriptVideoItem>[];
|
||||
final decodingErrors = <String>[];
|
||||
for (final map in maps) {
|
||||
if (map['items'] is List && !map.containsKey('id')) {
|
||||
try {
|
||||
items.addAll(decodeVideoItems(map['items']));
|
||||
} catch (error) {
|
||||
decodingErrors.add(error.toString());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
items.add(ScriptVideoItem.fromJson(_normalizeVideoItemMap(map)));
|
||||
} catch (error) {
|
||||
decodingErrors.add(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty && decodingErrors.isNotEmpty) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块返回了内容,但字段格式与 ForwardWidgets 契约不兼容',
|
||||
details: <String, Object?>{
|
||||
'itemCount': maps.length,
|
||||
'firstItem': _summarizeModuleResult(maps.first),
|
||||
'firstError': decodingErrors.first,
|
||||
},
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _normalizeVideoItemMap(Map<String, dynamic> source) {
|
||||
final normalized = Map<String, dynamic>.from(source);
|
||||
final fallbackId =
|
||||
normalized['link'] ??
|
||||
normalized['videoUrl'] ??
|
||||
normalized['url'] ??
|
||||
normalized['title'];
|
||||
normalized['id'] = normalized['id'] ?? fallbackId ?? '';
|
||||
const stringFieldNames = <String>{
|
||||
'id',
|
||||
'type',
|
||||
'title',
|
||||
'coverUrl',
|
||||
'posterPath',
|
||||
'posterUrl',
|
||||
'poster_url',
|
||||
'detailPoster',
|
||||
'backdropPath',
|
||||
'releaseDate',
|
||||
'mediaType',
|
||||
'genreTitle',
|
||||
'durationText',
|
||||
'previewUrl',
|
||||
'videoUrl',
|
||||
'link',
|
||||
'description',
|
||||
'playerType',
|
||||
};
|
||||
for (final fieldName in stringFieldNames) {
|
||||
final value = normalized[fieldName];
|
||||
if (value != null) normalized[fieldName] = value.toString();
|
||||
}
|
||||
normalized['title'] ??= '';
|
||||
normalized['type'] ??= 'link';
|
||||
|
||||
final subtitle = normalized['subTitle'] ?? normalized['subtitle'];
|
||||
if (subtitle != null &&
|
||||
(normalized['description'] == null ||
|
||||
normalized['description'].toString().isEmpty)) {
|
||||
normalized['description'] = subtitle.toString();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
bool _isExplicitlyEmptyModuleResult(Object? rawResult) {
|
||||
if (rawResult is List) return rawResult.isEmpty;
|
||||
if (rawResult is Map) return rawResult.isEmpty;
|
||||
return false;
|
||||
}
|
||||
|
||||
String _summarizeModuleResult(Object? rawResult) {
|
||||
final summary = rawResult?.toString() ?? 'null';
|
||||
const maximumSummaryLength = 500;
|
||||
if (summary.length <= maximumSummaryLength) return summary;
|
||||
return '${summary.substring(0, maximumSummaryLength)}…';
|
||||
}
|
||||
|
||||
List<ScriptWidgetContentSection> decodeVideoSections(
|
||||
Object? rawResult, {
|
||||
required String fallbackTitle,
|
||||
required bool preserveSections,
|
||||
}) {
|
||||
if (preserveSections && rawResult is List) {
|
||||
final sections = <ScriptWidgetContentSection>[];
|
||||
for (final rawSection in rawResult.whereType<Map>()) {
|
||||
final section = Map<String, dynamic>.from(rawSection);
|
||||
final rawItems =
|
||||
section['items'] ?? section['data'] ?? section['results'];
|
||||
if (rawItems == null) continue;
|
||||
final items = decodeVideoItems(rawItems);
|
||||
if (items.isEmpty) continue;
|
||||
sections.add(
|
||||
ScriptWidgetContentSection(
|
||||
title:
|
||||
section['title']?.toString() ??
|
||||
section['name']?.toString() ??
|
||||
fallbackTitle,
|
||||
items: items,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sections.isNotEmpty) return sections;
|
||||
}
|
||||
|
||||
final items = decodeVideoItems(rawResult);
|
||||
if (items.isEmpty) return const <ScriptWidgetContentSection>[];
|
||||
return <ScriptWidgetContentSection>[
|
||||
ScriptWidgetContentSection(title: fallbackTitle, items: items),
|
||||
];
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _decodeMapList(Object? rawResult) {
|
||||
if (rawResult is String) {
|
||||
final trimmedResult = rawResult.trim();
|
||||
if (trimmedResult.isEmpty) return const <Map<String, dynamic>>[];
|
||||
try {
|
||||
return _decodeMapList(jsonDecode(trimmedResult));
|
||||
} catch (_) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
}
|
||||
if (rawResult is Map) {
|
||||
final map = Map<String, dynamic>.from(rawResult);
|
||||
final nested = map['items'] ?? map['results'] ?? map['data'];
|
||||
if (!map.containsKey('id') && nested != null) {
|
||||
return _decodeMapList(nested);
|
||||
}
|
||||
return <Map<String, dynamic>>[map];
|
||||
}
|
||||
if (rawResult is! List) return const <Map<String, dynamic>>[];
|
||||
return rawResult
|
||||
.whereType<Map>()
|
||||
.map(Map<String, dynamic>.from)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
int _compareVersions(String left, String right) {
|
||||
final leftParts = _versionParts(left);
|
||||
final rightParts = _versionParts(right);
|
||||
final length = leftParts.length > rightParts.length
|
||||
? leftParts.length
|
||||
: rightParts.length;
|
||||
for (var index = 0; index < length; index++) {
|
||||
final leftValue = index < leftParts.length ? leftParts[index] : 0;
|
||||
final rightValue = index < rightParts.length ? rightParts[index] : 0;
|
||||
if (leftValue != rightValue) return leftValue.compareTo(rightValue);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<int> _versionParts(String version) {
|
||||
return version
|
||||
.split('.')
|
||||
.map((part) => int.tryParse(RegExp(r'\d+').stringMatch(part) ?? '') ?? 0)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _summarizeInvocationResult(Object? rawResult) {
|
||||
if (rawResult == null) return 'null';
|
||||
if (rawResult is List) {
|
||||
return 'List(length=${rawResult.length})';
|
||||
}
|
||||
if (rawResult is Map) {
|
||||
return 'Map(keys=${rawResult.keys.take(6).toList()}, size=${rawResult.length})';
|
||||
}
|
||||
final summary = rawResult.toString();
|
||||
const maximumSummaryLength = 200;
|
||||
if (summary.length <= maximumSummaryLength) {
|
||||
return '${rawResult.runtimeType} $summary';
|
||||
}
|
||||
return '${rawResult.runtimeType} ${summary.substring(0, maximumSummaryLength)}…';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../../contracts/library.dart';
|
||||
import '../ports/emby_gateway.dart';
|
||||
|
||||
|
||||
typedef SeriesResumeTarget = ({String episodeId, String? seasonId});
|
||||
|
||||
|
||||
Future<SeriesResumeTarget?> resolveSeriesResumeTarget(
|
||||
EmbyGateway gateway,
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId,
|
||||
) async {
|
||||
final nextUp = await gateway.getSeriesNextUp(ctx, seriesId, limit: 1);
|
||||
if (nextUp.isNotEmpty) {
|
||||
final ep = nextUp.first;
|
||||
return (episodeId: ep.Id, seasonId: ep.SeasonId);
|
||||
}
|
||||
final lastPlayed = await gateway.getLibraryItems(
|
||||
ctx: ctx,
|
||||
parentId: seriesId,
|
||||
includeItemTypes: 'Episode',
|
||||
recursive: true,
|
||||
filters: 'IsPlayed',
|
||||
sortBy: 'DatePlayed',
|
||||
sortOrder: SortOrder.descending,
|
||||
limit: 1,
|
||||
);
|
||||
if (lastPlayed.isNotEmpty) {
|
||||
final ep = lastPlayed.first;
|
||||
return (episodeId: ep.Id, seasonId: ep.SeasonId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../contracts/auth.dart';
|
||||
import '../../contracts/backup.dart';
|
||||
import '../../contracts/error.dart';
|
||||
import '../../contracts/script_widget.dart';
|
||||
import '../../contracts/server.dart';
|
||||
import '../errors.dart';
|
||||
import '../ports/danmaku_sources_store.dart';
|
||||
import '../ports/emby_gateway.dart';
|
||||
import '../ports/script_widget_repository.dart';
|
||||
import '../ports/server_repository.dart';
|
||||
import '../ports/server_sync_gateway.dart';
|
||||
import '../ports/session_repository.dart';
|
||||
import 'backup_usecases.dart';
|
||||
|
||||
|
||||
class ServerSyncUploadResult {
|
||||
final int serverCount;
|
||||
final int sessionCount;
|
||||
final int danmakuSourceCount;
|
||||
final int scriptWidgetCount;
|
||||
final int scriptWidgetSubscriptionCount;
|
||||
|
||||
const ServerSyncUploadResult({
|
||||
required this.serverCount,
|
||||
required this.sessionCount,
|
||||
required this.danmakuSourceCount,
|
||||
required this.scriptWidgetCount,
|
||||
required this.scriptWidgetSubscriptionCount,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class ServerSyncPullResult {
|
||||
final bool empty;
|
||||
final int serverCount;
|
||||
final int sessionCount;
|
||||
final int danmakuSourceCount;
|
||||
final int scriptWidgetCount;
|
||||
final int scriptWidgetSubscriptionCount;
|
||||
|
||||
const ServerSyncPullResult({
|
||||
required this.empty,
|
||||
required this.serverCount,
|
||||
required this.sessionCount,
|
||||
required this.danmakuSourceCount,
|
||||
required this.scriptWidgetCount,
|
||||
required this.scriptWidgetSubscriptionCount,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class UploadServerSyncUseCase {
|
||||
final ExportBackupUseCase export;
|
||||
final ServerSyncGateway gateway;
|
||||
|
||||
UploadServerSyncUseCase({required this.export, required this.gateway});
|
||||
|
||||
Future<ServerSyncUploadResult> execute({
|
||||
required String baseUrl,
|
||||
required String accessToken,
|
||||
}) async {
|
||||
final exported = await export.execute();
|
||||
await gateway.push(
|
||||
baseUrl: baseUrl,
|
||||
accessToken: accessToken,
|
||||
blob: exported.json,
|
||||
);
|
||||
return ServerSyncUploadResult(
|
||||
serverCount: exported.serverCount,
|
||||
sessionCount: exported.sessionCount,
|
||||
danmakuSourceCount: exported.danmakuSourceCount,
|
||||
scriptWidgetCount: exported.scriptWidgetCount,
|
||||
scriptWidgetSubscriptionCount: exported.scriptWidgetSubscriptionCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PullServerSyncUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
final SessionRepository sessionRepository;
|
||||
final DanmakuSourcesStore danmakuSourcesStore;
|
||||
final ScriptWidgetRepository scriptWidgetRepository;
|
||||
final ServerSyncGateway gateway;
|
||||
|
||||
PullServerSyncUseCase({
|
||||
required this.serverRepository,
|
||||
required this.sessionRepository,
|
||||
required this.danmakuSourcesStore,
|
||||
required this.scriptWidgetRepository,
|
||||
required this.gateway,
|
||||
});
|
||||
|
||||
Future<ServerSyncPullResult> execute({
|
||||
required String baseUrl,
|
||||
required String accessToken,
|
||||
}) async {
|
||||
final snapshot = await gateway.pull(
|
||||
baseUrl: baseUrl,
|
||||
accessToken: accessToken,
|
||||
);
|
||||
if (snapshot.isEmpty) {
|
||||
return const ServerSyncPullResult(
|
||||
empty: true,
|
||||
serverCount: 0,
|
||||
sessionCount: 0,
|
||||
danmakuSourceCount: 0,
|
||||
scriptWidgetCount: 0,
|
||||
scriptWidgetSubscriptionCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
final bundle = _parseBundle(snapshot.blob!);
|
||||
final prevActive = await sessionRepository.getActiveServerId();
|
||||
final oldServers = await serverRepository.list();
|
||||
final oldSessions = await sessionRepository.listAll();
|
||||
final oldScriptWidgets = await scriptWidgetRepository.listWidgets();
|
||||
final oldScriptWidgetSubscriptions =
|
||||
await scriptWidgetRepository.listSubscriptions();
|
||||
|
||||
final keepActive =
|
||||
prevActive != null &&
|
||||
bundle.sessions.any((s) => s.serverId == prevActive);
|
||||
final nextActive = keepActive
|
||||
? prevActive
|
||||
: (bundle.sessions.isNotEmpty ? bundle.sessions.first.serverId : null);
|
||||
|
||||
try {
|
||||
await serverRepository.replaceAll(bundle.servers);
|
||||
await sessionRepository.replaceAll(
|
||||
bundle.sessions,
|
||||
activeServerId: nextActive,
|
||||
);
|
||||
} catch (_) {
|
||||
await _restoreLocalSnapshot(
|
||||
servers: oldServers,
|
||||
sessions: oldSessions,
|
||||
activeServerId: prevActive,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final mergedSources = mergeDanmakuSourcesByUrl(
|
||||
local: await danmakuSourcesStore.list(),
|
||||
remote: bundle.danmakuSources,
|
||||
);
|
||||
await danmakuSourcesStore.replaceAll(mergedSources);
|
||||
|
||||
try {
|
||||
await scriptWidgetRepository.replaceAllWidgets(bundle.scriptWidgets);
|
||||
await scriptWidgetRepository.replaceAllSubscriptions(
|
||||
bundle.scriptWidgetSubscriptions,
|
||||
);
|
||||
} catch (_) {
|
||||
await _restoreScriptWidgets(
|
||||
widgets: oldScriptWidgets,
|
||||
subscriptions: oldScriptWidgetSubscriptions,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return ServerSyncPullResult(
|
||||
empty: false,
|
||||
serverCount: bundle.servers.length,
|
||||
sessionCount: bundle.sessions.length,
|
||||
danmakuSourceCount: mergedSources.length,
|
||||
scriptWidgetCount: bundle.scriptWidgets.length,
|
||||
scriptWidgetSubscriptionCount: bundle.scriptWidgetSubscriptions.length,
|
||||
);
|
||||
}
|
||||
|
||||
BackupBundle _parseBundle(String rawJson) {
|
||||
final BackupBundle bundle;
|
||||
try {
|
||||
final decoded = jsonDecode(rawJson);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw DomainError(ErrorCode.invalidParams, '云端同步数据格式不正确');
|
||||
}
|
||||
bundle = BackupBundle.fromJson(decoded);
|
||||
} on DomainError {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'云端同步数据解析失败',
|
||||
details: {'reason': e.toString()},
|
||||
);
|
||||
}
|
||||
if (bundle.version != kBackupSchemaVersion) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'不支持的同步数据版本:${bundle.version}',
|
||||
details: {'expected': kBackupSchemaVersion, 'got': bundle.version},
|
||||
);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
Future<void> _restoreLocalSnapshot({
|
||||
required List<EmbyServer> servers,
|
||||
required List<SessionData> sessions,
|
||||
required String? activeServerId,
|
||||
}) async {
|
||||
try {
|
||||
await serverRepository.replaceAll(servers);
|
||||
await sessionRepository.replaceAll(
|
||||
sessions,
|
||||
activeServerId: activeServerId,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _restoreScriptWidgets({
|
||||
required List<InstalledScriptWidget> widgets,
|
||||
required List<ScriptWidgetSubscription> subscriptions,
|
||||
}) async {
|
||||
try {
|
||||
await scriptWidgetRepository.replaceAllWidgets(widgets);
|
||||
await scriptWidgetRepository.replaceAllSubscriptions(subscriptions);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RepairSessionsUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
final SessionRepository sessionRepository;
|
||||
final EmbyGateway embyGateway;
|
||||
|
||||
RepairSessionsUseCase({
|
||||
required this.serverRepository,
|
||||
required this.sessionRepository,
|
||||
required this.embyGateway,
|
||||
});
|
||||
|
||||
Future<int> execute() async {
|
||||
final sessions = await sessionRepository.listAll();
|
||||
var repaired = 0;
|
||||
for (final session in sessions) {
|
||||
final server = await serverRepository.findById(session.serverId);
|
||||
if (server == null) continue;
|
||||
final ctx = AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
try {
|
||||
await embyGateway.getItemCounts(ctx);
|
||||
} on DomainError catch (e) {
|
||||
if (e.code != ErrorCode.authInvalidCredentials &&
|
||||
e.code != ErrorCode.authForbidden) {
|
||||
continue;
|
||||
}
|
||||
if (await _reauth(server.baseUrl, session)) repaired++;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
Future<bool> _reauth(String baseUrl, SessionData session) async {
|
||||
final pwd = session.password;
|
||||
if (pwd == null || pwd.isEmpty) return false;
|
||||
try {
|
||||
final auth = await embyGateway.authenticateByName(
|
||||
baseUrl: baseUrl,
|
||||
username: session.userName,
|
||||
password: pwd,
|
||||
);
|
||||
await sessionRepository.setMany([
|
||||
session.copyWith(token: auth.accessToken, userId: auth.userId),
|
||||
]);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../errors.dart';
|
||||
import '../ports/emby_gateway.dart';
|
||||
import '../ports/server_repository.dart';
|
||||
import '../ports/session_repository.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
class ListServersUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
ListServersUseCase({required this.serverRepository});
|
||||
|
||||
Future<List<EmbyServer>> execute() => serverRepository.list();
|
||||
}
|
||||
|
||||
class ReorderServersUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
|
||||
ReorderServersUseCase({required this.serverRepository});
|
||||
|
||||
Future<void> execute(List<EmbyServer> orderedServers) {
|
||||
return serverRepository.replaceAll(orderedServers);
|
||||
}
|
||||
}
|
||||
|
||||
class ProbeServerUseCase {
|
||||
final EmbyGateway embyGateway;
|
||||
ProbeServerUseCase({required this.embyGateway});
|
||||
|
||||
Future<ProbePublicInfoRes> execute(EmbyServerProbeReq input) async {
|
||||
final baseUrl = input.baseUrl.trim();
|
||||
if (baseUrl.isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '服务器地址不能为空');
|
||||
}
|
||||
return embyGateway.probePublicInfo(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
class SaveServerUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
final DateTime Function() now;
|
||||
final String Function() idGenerator;
|
||||
|
||||
SaveServerUseCase({
|
||||
required this.serverRepository,
|
||||
DateTime Function()? now,
|
||||
String Function()? idGenerator,
|
||||
}) : now = (now ?? DateTime.now),
|
||||
idGenerator = (idGenerator ?? _defaultIdGenerator);
|
||||
|
||||
static String _defaultIdGenerator() => _uuid.v4();
|
||||
|
||||
Future<EmbyServer> execute(EmbyServerSaveReq input) async {
|
||||
final name = input.name.trim();
|
||||
if (name.isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '服务器名称不能为空');
|
||||
}
|
||||
|
||||
final baseUrl = _normalizeBaseUrl(input.baseUrl);
|
||||
final nowIso = now().toUtc().toIso8601String();
|
||||
|
||||
final duplicated = await serverRepository.findByBaseUrl(baseUrl);
|
||||
if (duplicated != null && duplicated.id != input.id) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeConflict,
|
||||
'服务器地址已存在',
|
||||
details: {'baseUrl': baseUrl},
|
||||
);
|
||||
}
|
||||
|
||||
final iconUrl = input.iconUrl.trim();
|
||||
|
||||
if (input.id != null) {
|
||||
final current = await serverRepository.findById(input.id!);
|
||||
if (current == null) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
'服务器不存在',
|
||||
details: {'id': input.id},
|
||||
);
|
||||
}
|
||||
return serverRepository.update(
|
||||
current.copyWith(
|
||||
name: name,
|
||||
baseUrl: baseUrl,
|
||||
updatedAt: nowIso,
|
||||
iconUrl: iconUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return serverRepository.create(
|
||||
EmbyServer(
|
||||
id: idGenerator(),
|
||||
name: name,
|
||||
baseUrl: baseUrl,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
iconUrl: iconUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
String _normalizeBaseUrl(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '服务器地址不能为空');
|
||||
}
|
||||
|
||||
Uri parsed;
|
||||
try {
|
||||
parsed = Uri.parse(trimmed);
|
||||
} catch (_) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'服务器地址格式不正确',
|
||||
details: {'baseUrl': raw},
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.scheme != 'http' && parsed.scheme != 'https') {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'仅支持 HTTP/HTTPS 协议',
|
||||
details: {'baseUrl': raw},
|
||||
);
|
||||
}
|
||||
|
||||
final origin =
|
||||
'${parsed.scheme}://${parsed.host}'
|
||||
'${parsed.hasPort ? ':${parsed.port}' : ''}';
|
||||
final cleanPath = parsed.path.replaceAll(RegExp(r'/+$'), '');
|
||||
final normalizedPath = cleanPath.isEmpty ? '/' : cleanPath;
|
||||
|
||||
if (RegExp(r'/emby$', caseSensitive: false).hasMatch(normalizedPath)) {
|
||||
return '$origin$normalizedPath';
|
||||
}
|
||||
if (normalizedPath == '/') {
|
||||
return '$origin/emby';
|
||||
}
|
||||
return '$origin$normalizedPath/emby';
|
||||
}
|
||||
}
|
||||
|
||||
class TogglePauseServerUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
|
||||
TogglePauseServerUseCase({required this.serverRepository});
|
||||
|
||||
Future<EmbyServer> execute(String id) async {
|
||||
final current = await serverRepository.findById(id);
|
||||
if (current == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '服务器不存在', details: {'id': id});
|
||||
}
|
||||
return serverRepository.update(current.copyWith(paused: !current.paused));
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteServerUseCase {
|
||||
final ServerRepository serverRepository;
|
||||
final SessionRepository sessionRepository;
|
||||
|
||||
DeleteServerUseCase({
|
||||
required this.serverRepository,
|
||||
required this.sessionRepository,
|
||||
});
|
||||
|
||||
Future<void> execute(String id) async {
|
||||
final exists = await serverRepository.findById(id);
|
||||
if (exists == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '服务器不存在', details: {'id': id});
|
||||
}
|
||||
await serverRepository.deleteById(id);
|
||||
await sessionRepository.clear(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../errors.dart';
|
||||
import '../ports/emby_gateway.dart';
|
||||
import '../ports/server_repository.dart';
|
||||
import '../ports/session_repository.dart';
|
||||
|
||||
|
||||
Future<AuthedRequestContext> resolveAuthedContext({
|
||||
required SessionRepository sessionRepository,
|
||||
required ServerRepository serverRepository,
|
||||
}) async {
|
||||
final session = await sessionRepository.getActive();
|
||||
if (session == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '没有活跃会话');
|
||||
}
|
||||
final server = await serverRepository.findById(session.serverId);
|
||||
if (server == null) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
'服务器不存在',
|
||||
details: {'serverId': session.serverId},
|
||||
);
|
||||
}
|
||||
return AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<AuthedRequestContext?> resolveAuthedContextForServer({
|
||||
required SessionRepository sessionRepository,
|
||||
required ServerRepository serverRepository,
|
||||
required String serverId,
|
||||
}) async {
|
||||
final session = await sessionRepository.getByServerId(serverId);
|
||||
if (session == null) return null;
|
||||
final server = await serverRepository.findById(serverId);
|
||||
if (server == null) return null;
|
||||
return AuthedRequestContext(
|
||||
baseUrl: server.baseUrl,
|
||||
token: session.token,
|
||||
userId: session.userId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
abstract class AuthedUseCase {
|
||||
AuthedUseCase({
|
||||
required this.sessionRepository,
|
||||
required this.serverRepository,
|
||||
required this.embyGateway,
|
||||
});
|
||||
|
||||
final SessionRepository sessionRepository;
|
||||
final ServerRepository serverRepository;
|
||||
final EmbyGateway embyGateway;
|
||||
|
||||
|
||||
Future<AuthedRequestContext> ctx() => resolveAuthedContext(
|
||||
sessionRepository: sessionRepository,
|
||||
serverRepository: serverRepository,
|
||||
);
|
||||
|
||||
|
||||
Future<AuthedRequestContext> requireCtxForServer(String serverId) async {
|
||||
final ctx = await resolveAuthedContextForServer(
|
||||
sessionRepository: sessionRepository,
|
||||
serverRepository: serverRepository,
|
||||
serverId: serverId,
|
||||
);
|
||||
if (ctx == null) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
'服务器无活跃会话',
|
||||
details: {'serverId': serverId},
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user