Initial commit
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers/sm_account_provider.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/widgets/app_inline_alert.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/frosted_panel.dart';
|
||||
import 'widgets/otp_code_input.dart';
|
||||
|
||||
|
||||
class AccountAuthPage extends ConsumerStatefulWidget {
|
||||
const AccountAuthPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AccountAuthPage> createState() => _AccountAuthPageState();
|
||||
}
|
||||
|
||||
class _AccountAuthPageState extends ConsumerState<AccountAuthPage> {
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.appTokens;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
const _AuthBackground(),
|
||||
SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 32,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: FrostedPanel(
|
||||
radius: tokens.radiusCardCompact,
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.05)
|
||||
: Colors.black.withValues(alpha: 0.03),
|
||||
blurSigma: 15,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(28, 32, 28, 28),
|
||||
child: AccountAuthForm(
|
||||
onBusyChanged: (v) => setState(() => _busy = v),
|
||||
onSuccess: (message) => context.pop(message),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: IconButton(
|
||||
onPressed: _busy ? null : () => context.pop(),
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountAuthForm extends ConsumerStatefulWidget {
|
||||
final ValueChanged<String> onSuccess;
|
||||
final ValueChanged<bool>? onBusyChanged;
|
||||
final bool autofocus;
|
||||
|
||||
const AccountAuthForm({
|
||||
super.key,
|
||||
required this.onSuccess,
|
||||
this.onBusyChanged,
|
||||
this.autofocus = true,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AccountAuthForm> createState() => _AccountAuthFormState();
|
||||
}
|
||||
|
||||
enum _AuthMode { login, register }
|
||||
|
||||
class _AccountAuthFormState extends ConsumerState<AccountAuthForm> {
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
final _emailFocus = FocusNode();
|
||||
final _passwordFocus = FocusNode();
|
||||
|
||||
_AuthMode _mode = _AuthMode.login;
|
||||
|
||||
|
||||
bool _awaitingCode = false;
|
||||
bool _busy = false;
|
||||
bool _obscure = true;
|
||||
|
||||
String _code = '';
|
||||
|
||||
|
||||
String? _formError;
|
||||
|
||||
|
||||
String? _emailError;
|
||||
String? _passwordError;
|
||||
|
||||
|
||||
int _resendIn = 0;
|
||||
Timer? _resendTimer;
|
||||
|
||||
bool get _isRegister => _mode == _AuthMode.register;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resendTimer?.cancel();
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
_emailFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startResendCountdown() {
|
||||
_resendTimer?.cancel();
|
||||
setState(() => _resendIn = 60);
|
||||
_resendTimer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) {
|
||||
t.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_resendIn -= 1;
|
||||
if (_resendIn <= 0) t.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _switchMode(_AuthMode mode) {
|
||||
if (_busy) return;
|
||||
setState(() {
|
||||
_mode = mode;
|
||||
_awaitingCode = false;
|
||||
_formError = null;
|
||||
_emailError = null;
|
||||
_passwordError = null;
|
||||
});
|
||||
}
|
||||
|
||||
static final _emailRe = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
|
||||
bool _validateForm() {
|
||||
final email = _emailCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
String? emailErr;
|
||||
String? passErr;
|
||||
if (email.isEmpty) {
|
||||
emailErr = '请输入邮箱';
|
||||
} else if (!_emailRe.hasMatch(email)) {
|
||||
emailErr = '邮箱格式不正确';
|
||||
}
|
||||
if (password.isEmpty) {
|
||||
passErr = '请输入密码';
|
||||
} else if (_isRegister && password.length < 6) {
|
||||
passErr = '密码至少 6 位';
|
||||
}
|
||||
setState(() {
|
||||
_emailError = emailErr;
|
||||
_passwordError = passErr;
|
||||
_formError = null;
|
||||
});
|
||||
return emailErr == null && passErr == null;
|
||||
}
|
||||
|
||||
bool _validateEmailOnly() {
|
||||
final email = _emailCtrl.text.trim();
|
||||
String? emailErr;
|
||||
if (email.isEmpty) {
|
||||
emailErr = '请输入邮箱';
|
||||
} else if (!_emailRe.hasMatch(email)) {
|
||||
emailErr = '邮箱格式不正确';
|
||||
}
|
||||
setState(() {
|
||||
_emailError = emailErr;
|
||||
_formError = null;
|
||||
});
|
||||
return emailErr == null;
|
||||
}
|
||||
|
||||
bool _validateRegisterPassword() {
|
||||
final password = _passwordCtrl.text;
|
||||
String? passErr;
|
||||
if (password.isEmpty) {
|
||||
passErr = '请输入密码';
|
||||
} else if (password.length < 6) {
|
||||
passErr = '密码至少 6 位';
|
||||
}
|
||||
setState(() {
|
||||
_passwordError = passErr;
|
||||
_formError = null;
|
||||
});
|
||||
return passErr == null;
|
||||
}
|
||||
|
||||
void _setBusy(bool value) {
|
||||
setState(() => _busy = value);
|
||||
widget.onBusyChanged?.call(value);
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
if (!_validateForm()) return;
|
||||
_setBusy(true);
|
||||
final result = await ref
|
||||
.read(smAccountProvider.notifier)
|
||||
.login(_emailCtrl.text.trim(), _passwordCtrl.text);
|
||||
if (!mounted) return;
|
||||
_setBusy(false);
|
||||
|
||||
if (result.ok) {
|
||||
widget.onSuccess('登录成功');
|
||||
} else if (result.needsVerification) {
|
||||
setState(() {
|
||||
_mode = _AuthMode.register;
|
||||
_awaitingCode = true;
|
||||
_code = '';
|
||||
_formError = result.message ?? '邮箱尚未验证,请输入验证码完成激活';
|
||||
});
|
||||
_startResendCountdown();
|
||||
} else {
|
||||
setState(() => _formError = result.message ?? '登录失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerStart() async {
|
||||
if (!_validateEmailOnly()) return;
|
||||
_setBusy(true);
|
||||
final result = await ref
|
||||
.read(smAccountProvider.notifier)
|
||||
.registerStart(_emailCtrl.text.trim());
|
||||
if (!mounted) return;
|
||||
_setBusy(false);
|
||||
|
||||
if (result.ok) {
|
||||
setState(() {
|
||||
_awaitingCode = true;
|
||||
_code = '';
|
||||
_formError = null;
|
||||
});
|
||||
_startResendCountdown();
|
||||
} else {
|
||||
setState(() => _formError = result.message ?? '发送验证码失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerVerify() async {
|
||||
if (!_validateRegisterPassword()) return;
|
||||
_setBusy(true);
|
||||
final result = await ref
|
||||
.read(smAccountProvider.notifier)
|
||||
.registerVerify(_emailCtrl.text.trim(), _passwordCtrl.text, _code);
|
||||
if (!mounted) return;
|
||||
_setBusy(false);
|
||||
|
||||
if (result.ok) {
|
||||
widget.onSuccess('注册成功,已登录');
|
||||
} else {
|
||||
setState(() => _formError = result.message ?? '验证失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resend() async {
|
||||
if (_resendIn > 0 || _busy) return;
|
||||
_setBusy(true);
|
||||
final result = await ref
|
||||
.read(smAccountProvider.notifier)
|
||||
.resendRegisterCode(_emailCtrl.text.trim());
|
||||
if (!mounted) return;
|
||||
_setBusy(false);
|
||||
if (result.ok) {
|
||||
_startResendCountdown();
|
||||
setState(() => _formError = null);
|
||||
} else {
|
||||
setState(() => _formError = result.message ?? '重发失败');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
key: const ValueKey('form'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _BrandHeader(),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_isRegister ? '创建 player 账号' : '登录以使用 player 账号服务',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_formError != null) ...[
|
||||
AppInlineAlert(message: _formError!),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextField(
|
||||
controller: _emailCtrl,
|
||||
focusNode: _emailFocus,
|
||||
enabled: !_busy,
|
||||
autofocus: widget.autofocus,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: InputDecoration(
|
||||
labelText: '邮箱',
|
||||
hintText: 'your@email.com',
|
||||
errorText: _emailError,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_emailError != null) setState(() => _emailError = null);
|
||||
},
|
||||
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
),
|
||||
if (_isRegister) ...[
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: (_busy || _resendIn > 0)
|
||||
? null
|
||||
: (_awaitingCode ? _resend : _registerStart),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 4,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
_awaitingCode
|
||||
? (_resendIn > 0 ? '重新发送(${_resendIn}s)' : '重新发送验证码')
|
||||
: '发送验证码',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordCtrl,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !_busy,
|
||||
obscureText: _obscure,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
hintText: _isRegister ? '设置密码(至少 6 位)' : '输入密码',
|
||||
errorText: _passwordError,
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
icon: Icon(
|
||||
_obscure
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
),
|
||||
tooltip: _obscure ? '显示密码' : '隐藏密码',
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_passwordError != null) {
|
||||
setState(() => _passwordError = null);
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (!_isRegister) _login();
|
||||
},
|
||||
),
|
||||
if (_isRegister)
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _awaitingCode
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
||||
child: Text(
|
||||
'验证码',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
OtpCodeInput(
|
||||
enabled: !_busy,
|
||||
foreground: theme.colorScheme.onSurface,
|
||||
accent: theme.colorScheme.primary,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_code = v;
|
||||
if (_formError != null) _formError = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: FilledButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: (_isRegister
|
||||
? ((!_awaitingCode ||
|
||||
_code.length < 6 ||
|
||||
_passwordCtrl.text.length < 6)
|
||||
? null
|
||||
: _registerVerify)
|
||||
: _login),
|
||||
child: _busy
|
||||
? const AppLoadingRing(size: 18, color: Colors.white)
|
||||
: Text(
|
||||
_isRegister ? '完成注册' : '登录',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ModeSwitchLink(
|
||||
hint: _isRegister ? '已有账号?' : '还没有账号?',
|
||||
action: _isRegister ? '去登录' : '去注册',
|
||||
onTap: _busy
|
||||
? null
|
||||
: () => _switchMode(
|
||||
_isRegister ? _AuthMode.login : _AuthMode.register,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _AuthBackground extends StatelessWidget {
|
||||
const _AuthBackground();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [tokens.pageGradientTop, tokens.pageGradientBottom],
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: const Alignment(0, -1.3),
|
||||
child: Container(
|
||||
width: 460,
|
||||
height: 460,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
theme.colorScheme.primary.withValues(alpha: 0.22),
|
||||
theme.colorScheme.primary.withValues(alpha: 0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _BrandHeader extends StatelessWidget {
|
||||
const _BrandHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset(
|
||||
'assets/icon/app_icon.png',
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: Colors.white,
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('smPlayer', style: theme.textTheme.titleMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _ModeSwitchLink extends StatelessWidget {
|
||||
final String hint;
|
||||
final String action;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ModeSwitchLink({
|
||||
required this.hint,
|
||||
required this.action,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
hint,
|
||||
style: TextStyle(color: tokens.mutedForeground, fontSize: 13),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Text(
|
||||
action,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
|
||||
class OtpCodeInput extends StatefulWidget {
|
||||
|
||||
final int length;
|
||||
|
||||
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
|
||||
final ValueChanged<String>? onCompleted;
|
||||
|
||||
|
||||
final bool enabled;
|
||||
|
||||
|
||||
final bool autofocus;
|
||||
|
||||
|
||||
final Color foreground;
|
||||
|
||||
|
||||
final Color accent;
|
||||
|
||||
const OtpCodeInput({
|
||||
super.key,
|
||||
this.length = 6,
|
||||
required this.onChanged,
|
||||
this.onCompleted,
|
||||
this.enabled = true,
|
||||
this.autofocus = true,
|
||||
this.foreground = Colors.white,
|
||||
this.accent = const Color(0xFF1677FF),
|
||||
});
|
||||
|
||||
@override
|
||||
State<OtpCodeInput> createState() => _OtpCodeInputState();
|
||||
}
|
||||
|
||||
class _OtpCodeInputState extends State<OtpCodeInput> {
|
||||
late final List<TextEditingController> _controllers;
|
||||
late final List<FocusNode> _nodes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controllers = List.generate(widget.length, (_) => TextEditingController());
|
||||
_nodes = List.generate(widget.length, (_) {
|
||||
final node = FocusNode();
|
||||
node.addListener(() {
|
||||
if (node.hasFocus) {
|
||||
final i = _nodes.indexOf(node);
|
||||
final c = _controllers[i];
|
||||
c.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: c.text.length,
|
||||
);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final n in _nodes) {
|
||||
n.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _value => _controllers.map((c) => c.text).join();
|
||||
|
||||
void _emit() {
|
||||
final v = _value;
|
||||
widget.onChanged(v);
|
||||
if (v.length == widget.length) widget.onCompleted?.call(v);
|
||||
}
|
||||
|
||||
|
||||
void _spread(int startIndex, String digits) {
|
||||
var idx = startIndex;
|
||||
for (final ch in digits.split('')) {
|
||||
if (idx >= widget.length) break;
|
||||
_controllers[idx].text = ch;
|
||||
idx++;
|
||||
}
|
||||
final focusIndex = idx >= widget.length ? widget.length - 1 : idx;
|
||||
_nodes[focusIndex].requestFocus();
|
||||
_emit();
|
||||
}
|
||||
|
||||
void _onChanged(int index, String raw) {
|
||||
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.isEmpty) {
|
||||
_controllers[index].text = '';
|
||||
_emit();
|
||||
return;
|
||||
}
|
||||
if (digits.length == 1) {
|
||||
final c = _controllers[index];
|
||||
c.text = digits;
|
||||
c.selection = TextSelection.collapsed(offset: c.text.length);
|
||||
if (index < widget.length - 1) _nodes[index + 1].requestFocus();
|
||||
_emit();
|
||||
} else {
|
||||
_spread(index, digits);
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(int index, KeyEvent event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.backspace &&
|
||||
_controllers[index].text.isEmpty &&
|
||||
index > 0) {
|
||||
_controllers[index - 1].text = '';
|
||||
_nodes[index - 1].requestFocus();
|
||||
_emit();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(widget.length, (i) {
|
||||
final focused = _nodes[i].hasFocus;
|
||||
final filled = _controllers[i].text.isNotEmpty;
|
||||
final borderColor = focused
|
||||
? widget.accent
|
||||
: widget.foreground.withValues(alpha: filled ? 0.45 : 0.18);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: SizedBox(
|
||||
width: 46,
|
||||
height: 56,
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: (_, event) => _onKey(i, event),
|
||||
child: TextField(
|
||||
controller: _controllers[i],
|
||||
focusNode: _nodes[i],
|
||||
enabled: widget.enabled,
|
||||
autofocus: widget.autofocus && i == 0,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
cursorColor: widget.accent,
|
||||
showCursor: true,
|
||||
style: TextStyle(
|
||||
color: widget.foreground,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: widget.foreground.withValues(alpha: 0.06),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: borderColor, width: 1.2),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: widget.accent, width: 1.6),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: borderColor, width: 1.2),
|
||||
),
|
||||
),
|
||||
onChanged: (v) => _onChanged(i, v),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
|
||||
|
||||
library;
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../shared/utils/time_bucket.dart';
|
||||
|
||||
|
||||
enum GroupAxis { watchedTime, addedTime, mediaType }
|
||||
|
||||
|
||||
class AggregatedEntrySource {
|
||||
final EmbyRawItem item;
|
||||
final GlobalSearchServerResult source;
|
||||
|
||||
const AggregatedEntrySource({required this.item, required this.source});
|
||||
|
||||
|
||||
DateTime? dateFor(GroupAxis axis) {
|
||||
switch (axis) {
|
||||
case GroupAxis.watchedTime:
|
||||
final userData = item.extra['UserData'];
|
||||
return parseEmbyDate(
|
||||
userData is Map ? userData['LastPlayedDate'] : null,
|
||||
);
|
||||
case GroupAxis.addedTime:
|
||||
return parseEmbyDate(item.extra['DateCreated']);
|
||||
case GroupAxis.mediaType:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AggregatedEntry {
|
||||
final EmbyRawItem item;
|
||||
final GlobalSearchServerResult source;
|
||||
final List<AggregatedEntrySource> sources;
|
||||
|
||||
AggregatedEntry({
|
||||
required this.item,
|
||||
required this.source,
|
||||
List<AggregatedEntrySource>? sources,
|
||||
}) : assert(sources == null || sources.isNotEmpty),
|
||||
sources = List.unmodifiable(
|
||||
sources ?? [AggregatedEntrySource(item: item, source: source)],
|
||||
);
|
||||
|
||||
|
||||
DateTime? dateFor(GroupAxis axis) {
|
||||
return sources.first.dateFor(axis);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AggregatedGroup {
|
||||
final String key;
|
||||
final String label;
|
||||
final int order;
|
||||
final List<AggregatedEntry> entries;
|
||||
|
||||
const AggregatedGroup({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.order,
|
||||
required this.entries,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
List<AggregatedEntry> flatten(
|
||||
List<GlobalSearchServerResult> results, {
|
||||
String? serverFilter,
|
||||
bool deduplicateAcrossServers = false,
|
||||
}) {
|
||||
final out = <AggregatedEntry>[];
|
||||
for (final sr in results) {
|
||||
if (serverFilter != null && sr.serverId != serverFilter) continue;
|
||||
for (final item in sr.items) {
|
||||
out.add(AggregatedEntry(item: item, source: sr));
|
||||
}
|
||||
}
|
||||
if (deduplicateAcrossServers && serverFilter == null && out.length > 1) {
|
||||
return _deduplicateEntries(out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const _typeOrder = <String, ({String label, int order})>{
|
||||
'Movie': (label: '电影', order: 0),
|
||||
'Series': (label: '剧集', order: 1),
|
||||
'Episode': (label: '单集', order: 2),
|
||||
};
|
||||
const _typeOther = (label: '其它', order: 3);
|
||||
|
||||
|
||||
List<AggregatedGroup> groupEntries(
|
||||
List<AggregatedEntry> entries,
|
||||
GroupAxis axis,
|
||||
DateTime now,
|
||||
) {
|
||||
if (axis == GroupAxis.mediaType) {
|
||||
return _groupByType(entries);
|
||||
}
|
||||
return _groupByTime(entries, axis, now);
|
||||
}
|
||||
|
||||
List<AggregatedGroup> _groupByTime(
|
||||
List<AggregatedEntry> entries,
|
||||
GroupAxis axis,
|
||||
DateTime now,
|
||||
) {
|
||||
final scheme = axis == GroupAxis.watchedTime
|
||||
? TimeBucketScheme.watched
|
||||
: TimeBucketScheme.added;
|
||||
final buckets =
|
||||
<String, ({TimeBucket bucket, List<AggregatedEntry> items})>{};
|
||||
|
||||
for (final e in entries) {
|
||||
final b = bucketFor(e.dateFor(axis), now, scheme);
|
||||
(buckets[b.key] ??= (bucket: b, items: [])).items.add(e);
|
||||
}
|
||||
|
||||
final groups = buckets.values.map((v) {
|
||||
final items = [...v.items]
|
||||
..sort((a, b) {
|
||||
final da = a.dateFor(axis);
|
||||
final db = b.dateFor(axis);
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return db.compareTo(da);
|
||||
});
|
||||
return AggregatedGroup(
|
||||
key: v.bucket.key,
|
||||
label: v.bucket.label,
|
||||
order: v.bucket.order,
|
||||
entries: items,
|
||||
);
|
||||
}).toList()..sort((a, b) => a.order.compareTo(b.order));
|
||||
return groups;
|
||||
}
|
||||
|
||||
List<AggregatedGroup> _groupByType(List<AggregatedEntry> entries) {
|
||||
final groups =
|
||||
<String, ({String label, int order, List<AggregatedEntry> items})>{};
|
||||
|
||||
for (final e in entries) {
|
||||
final meta = _typeOrder[e.item.Type] ?? _typeOther;
|
||||
final key = e.item.Type ?? 'Other';
|
||||
(groups[key] ??= (
|
||||
label: meta.label,
|
||||
order: meta.order,
|
||||
items: [],
|
||||
)).items.add(e);
|
||||
}
|
||||
|
||||
final out = groups.entries.map((kv) {
|
||||
final items = [...kv.value.items]
|
||||
..sort((a, b) => (a.item.Name).compareTo(b.item.Name));
|
||||
return AggregatedGroup(
|
||||
key: kv.key,
|
||||
label: kv.value.label,
|
||||
order: kv.value.order,
|
||||
entries: items,
|
||||
);
|
||||
}).toList()..sort((a, b) => a.order.compareTo(b.order));
|
||||
return out;
|
||||
}
|
||||
|
||||
Set<String> _candidateKeys(EmbyRawItem item) {
|
||||
final type = item.Type;
|
||||
if (type == null || type.isEmpty) return const {};
|
||||
final ids = normalizedProviderIdsOfItem(item);
|
||||
if (ids.isEmpty) return const {};
|
||||
return {for (final e in ids.entries) '$type:${e.key}:${e.value}'};
|
||||
}
|
||||
|
||||
List<AggregatedEntry> _deduplicateEntries(List<AggregatedEntry> entries) {
|
||||
final uf = _UnionFind(entries.length);
|
||||
final keyToIndex = <String, int>{};
|
||||
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
final keys = _candidateKeys(entries[i].item);
|
||||
for (final k in keys) {
|
||||
final prev = keyToIndex[k];
|
||||
if (prev != null) {
|
||||
uf.union(prev, i);
|
||||
} else {
|
||||
keyToIndex[k] = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final groups = uf.groups();
|
||||
final survivors = <AggregatedEntry>[];
|
||||
for (final group in groups) {
|
||||
final orderedIndices = [...group]
|
||||
..sort((leftIndex, rightIndex) {
|
||||
final leftDate = _lastPlayedDate(entries[leftIndex]);
|
||||
final rightDate = _lastPlayedDate(entries[rightIndex]);
|
||||
if (leftDate == null && rightDate == null) {
|
||||
return leftIndex.compareTo(rightIndex);
|
||||
}
|
||||
if (leftDate == null) return 1;
|
||||
if (rightDate == null) return -1;
|
||||
|
||||
final dateComparison = rightDate.compareTo(leftDate);
|
||||
return dateComparison != 0
|
||||
? dateComparison
|
||||
: leftIndex.compareTo(rightIndex);
|
||||
});
|
||||
|
||||
final sourceServerIds = <String>{};
|
||||
final sources = <AggregatedEntrySource>[];
|
||||
for (final entryIndex in orderedIndices) {
|
||||
final entry = entries[entryIndex];
|
||||
if (!sourceServerIds.add(entry.source.serverId)) continue;
|
||||
sources.add(
|
||||
AggregatedEntrySource(item: entry.item, source: entry.source),
|
||||
);
|
||||
}
|
||||
|
||||
final primarySource = sources.first;
|
||||
survivors.add(
|
||||
AggregatedEntry(
|
||||
item: primarySource.item,
|
||||
source: primarySource.source,
|
||||
sources: sources,
|
||||
),
|
||||
);
|
||||
}
|
||||
return survivors;
|
||||
}
|
||||
|
||||
DateTime? _lastPlayedDate(AggregatedEntry e) =>
|
||||
e.dateFor(GroupAxis.watchedTime);
|
||||
|
||||
class _UnionFind {
|
||||
final List<int> _parent;
|
||||
final List<int> _rank;
|
||||
|
||||
_UnionFind(int n)
|
||||
: _parent = List.generate(n, (i) => i),
|
||||
_rank = List.filled(n, 0);
|
||||
|
||||
int find(int x) {
|
||||
if (_parent[x] != x) _parent[x] = find(_parent[x]);
|
||||
return _parent[x];
|
||||
}
|
||||
|
||||
void union(int a, int b) {
|
||||
final ra = find(a), rb = find(b);
|
||||
if (ra == rb) return;
|
||||
if (_rank[ra] < _rank[rb]) {
|
||||
_parent[ra] = rb;
|
||||
} else if (_rank[ra] > _rank[rb]) {
|
||||
_parent[rb] = ra;
|
||||
} else {
|
||||
_parent[rb] = ra;
|
||||
_rank[ra]++;
|
||||
}
|
||||
}
|
||||
|
||||
List<List<int>> groups() {
|
||||
final map = <int, List<int>>{};
|
||||
for (var i = 0; i < _parent.length; i++) {
|
||||
(map[find(i)] ??= []).add(i);
|
||||
}
|
||||
return map.values.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/utils/time_bucket.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import 'aggregated_grouping.dart';
|
||||
import 'widgets/aggregated_grouped_grid.dart';
|
||||
import 'widgets/aggregated_media_card.dart';
|
||||
import 'widgets/server_filter_bar.dart';
|
||||
|
||||
class AggregatedRecentPage extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const AggregatedRecentPage({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AggregatedRecentPage> createState() =>
|
||||
_AggregatedRecentPageState();
|
||||
}
|
||||
|
||||
class _AggregatedRecentPageState extends ConsumerState<AggregatedRecentPage> {
|
||||
StreamSubscription<GlobalSearchServerResult>? _subscription;
|
||||
|
||||
List<GlobalSearchServerResult> _serverResults = [];
|
||||
bool _loading = false;
|
||||
bool _switching = false;
|
||||
|
||||
final Set<String> _collapsed = {};
|
||||
String? _serverFilter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_serverResults = [];
|
||||
});
|
||||
|
||||
try {
|
||||
final uc = await ref.read(aggregatedRecentUseCaseProvider.future);
|
||||
final stream = uc.executeStream();
|
||||
|
||||
_subscription = stream.listen(
|
||||
(result) {
|
||||
if (mounted) {
|
||||
setState(() => _serverResults = [..._serverResults, result]);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
onError: (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onItemTap(
|
||||
BuildContext context,
|
||||
GlobalSearchServerResult serverResult,
|
||||
EmbyRawItem item,
|
||||
) async {
|
||||
if (_switching) return;
|
||||
setState(() => _switching = true);
|
||||
try {
|
||||
await goMediaDetailOnServer(
|
||||
context,
|
||||
ref,
|
||||
serverId: serverResult.serverId,
|
||||
item: item,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _switching = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (!_collapsed.remove(key)) _collapsed.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(sessionProvider, (previous, next) {
|
||||
final prevIds =
|
||||
previous?.value?.sessions.map((s) => s.serverId).toSet() ??
|
||||
<String>{};
|
||||
final nextIds =
|
||||
next.value?.sessions.map((s) => s.serverId).toSet() ?? <String>{};
|
||||
if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) {
|
||||
return;
|
||||
}
|
||||
if (_serverFilter != null && !nextIds.contains(_serverFilter)) {
|
||||
setState(() => _serverFilter = null);
|
||||
}
|
||||
_load();
|
||||
});
|
||||
|
||||
if (widget.embedded) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverOverlapInjector(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context,
|
||||
),
|
||||
),
|
||||
..._buildContentSlivers(context),
|
||||
],
|
||||
),
|
||||
if (_switching) _switchingOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
title: '最近添加',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loading ? null : _load,
|
||||
icon: _loading
|
||||
? const AppLoadingRing(size: 16)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
..._buildContentSlivers(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_switching) _switchingOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchingOverlay() => const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x44000000),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _buildContentSlivers(BuildContext context) {
|
||||
if (_serverResults.isEmpty && !_loading) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(
|
||||
icon: Icons.new_releases_outlined,
|
||||
title: '暂无最近添加内容',
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_serverResults.isEmpty && _loading) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final entries = flatten(
|
||||
_serverResults,
|
||||
serverFilter: _serverFilter,
|
||||
deduplicateAcrossServers: true,
|
||||
);
|
||||
final groups = groupEntries(entries, GroupAxis.addedTime, now);
|
||||
final servers = _serverResults
|
||||
.map((sr) => (id: sr.serverId, name: sr.serverName))
|
||||
.toList(growable: false);
|
||||
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ServerFilterBar(
|
||||
servers: servers,
|
||||
selected: _serverFilter,
|
||||
onSelect: (v) => setState(() => _serverFilter = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
...buildAggregatedGridSlivers(
|
||||
groups: groups,
|
||||
collapsed: _collapsed,
|
||||
onToggle: _toggle,
|
||||
cardBuilder: (entry) => _card(context, entry, now),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context, AggregatedEntry entry, DateTime now) {
|
||||
final sr = entry.source;
|
||||
final item = entry.item;
|
||||
final subtitle = item.ProductionYear != null
|
||||
? '${item.ProductionYear}'
|
||||
: null;
|
||||
|
||||
return AggregatedMediaCard(
|
||||
item: item,
|
||||
imageUrl: EmbyImageUrl.landscapeCard(
|
||||
baseUrl: sr.serverUrl,
|
||||
token: sr.token,
|
||||
item: item,
|
||||
maxWidth: 400,
|
||||
),
|
||||
imageHeaders: buildEmbyImageHeaders(
|
||||
ref,
|
||||
token: sr.token,
|
||||
userId: sr.userId,
|
||||
),
|
||||
title: item.Name,
|
||||
subtitle: subtitle,
|
||||
serverName: sr.serverName,
|
||||
timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.addedTime), now),
|
||||
showProgress: false,
|
||||
onTap: () => _onItemTap(context, sr, item),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/page_background.dart';
|
||||
import '../../shared/widgets/pill_tab_bar.dart';
|
||||
import '../favorites/aggregated_favorites_page.dart';
|
||||
import '../history/history_page.dart';
|
||||
import 'aggregated_recent_page.dart';
|
||||
|
||||
const _kTabHistory = 'history';
|
||||
const _kTabFavorites = 'favorites';
|
||||
const _kTabRecent = 'recent';
|
||||
|
||||
int _tabIndexFromKey(String? key) => switch (key) {
|
||||
_kTabFavorites => 1,
|
||||
_kTabRecent => 2,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
String _tabKeyFromIndex(int index) => switch (index) {
|
||||
1 => _kTabFavorites,
|
||||
2 => _kTabRecent,
|
||||
_ => _kTabHistory,
|
||||
};
|
||||
|
||||
class AggregatedViewPage extends ConsumerStatefulWidget {
|
||||
|
||||
final String? initialTab;
|
||||
|
||||
|
||||
final bool isCompactMode;
|
||||
|
||||
const AggregatedViewPage({
|
||||
super.key,
|
||||
this.initialTab,
|
||||
this.isCompactMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AggregatedViewPage> createState() => _AggregatedViewPageState();
|
||||
}
|
||||
|
||||
class _AggregatedViewPageState extends ConsumerState<AggregatedViewPage> {
|
||||
late int _index;
|
||||
int _historyRefreshSeed = 0;
|
||||
int _favoritesRefreshSeed = 0;
|
||||
int _recentRefreshSeed = 0;
|
||||
|
||||
String get _storagePrefix => widget.isCompactMode ? 'watch' : 'aggregated';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_index = _tabIndexFromKey(widget.initialTab);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AggregatedViewPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialTab != oldWidget.initialTab) {
|
||||
final next = _tabIndexFromKey(widget.initialTab);
|
||||
if (next != _index) {
|
||||
setState(() => _index = next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onTabChanged(int next) {
|
||||
if (next == _index) return;
|
||||
setState(() => _index = next);
|
||||
final key = _tabKeyFromIndex(next);
|
||||
context.go('/aggregated?tab=$key');
|
||||
}
|
||||
|
||||
Future<void> _refreshActive() async {
|
||||
setState(() {
|
||||
switch (_index) {
|
||||
case 1:
|
||||
_favoritesRefreshSeed++;
|
||||
break;
|
||||
case 2:
|
||||
_recentRefreshSeed++;
|
||||
break;
|
||||
default:
|
||||
_historyRefreshSeed++;
|
||||
}
|
||||
});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.appTokens;
|
||||
final firstTabLabel = widget.isCompactMode ? '继续观看' : '播放记录';
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: PageBackground()),
|
||||
RefreshIndicator(
|
||||
onRefresh: _refreshActive,
|
||||
child: NestedScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
floatHeaderSlivers: false,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context,
|
||||
),
|
||||
sliver: AppSliverHeader(
|
||||
toolbarHeight: tokens.titleBarHeight,
|
||||
automaticallyImplyLeading: false,
|
||||
titleWidget: _buildTitleRow(firstTabLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: [
|
||||
KeyedSubtree(
|
||||
key: PageStorageKey(
|
||||
'${_storagePrefix}_history_$_historyRefreshSeed',
|
||||
),
|
||||
child: const HistoryPage(embedded: true),
|
||||
),
|
||||
KeyedSubtree(
|
||||
key: PageStorageKey(
|
||||
'${_storagePrefix}_favorites_$_favoritesRefreshSeed',
|
||||
),
|
||||
child: const AggregatedFavoritesPage(embedded: true),
|
||||
),
|
||||
KeyedSubtree(
|
||||
key: PageStorageKey(
|
||||
'${_storagePrefix}_recent_$_recentRefreshSeed',
|
||||
),
|
||||
child: const AggregatedRecentPage(embedded: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitleRow(String firstTabLabel) {
|
||||
final tabBar = PillTabBar(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
tabs: [firstTabLabel, '我的收藏', '最近添加'],
|
||||
selectedIndex: _index,
|
||||
onSelect: _onTabChanged,
|
||||
);
|
||||
|
||||
return Align(alignment: Alignment.centerLeft, child: tabBar);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/layout/adaptive_card_grid.dart';
|
||||
import '../../../shared/widgets/section_header.dart';
|
||||
import 'aggregated_media_card.dart';
|
||||
import '../aggregated_grouping.dart';
|
||||
|
||||
|
||||
List<Widget> buildAggregatedGridSlivers({
|
||||
required List<AggregatedGroup> groups,
|
||||
required Set<String> collapsed,
|
||||
required ValueChanged<String> onToggle,
|
||||
required Widget Function(AggregatedEntry entry) cardBuilder,
|
||||
EdgeInsets padding = const EdgeInsets.fromLTRB(24, 4, 24, 24),
|
||||
}) {
|
||||
final slivers = <Widget>[];
|
||||
for (final group in groups) {
|
||||
final isCollapsed = collapsed.contains(group.key);
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: padding.left, right: padding.right),
|
||||
child: SectionHeader(
|
||||
label: group.label,
|
||||
count: group.entries.length,
|
||||
collapsible: true,
|
||||
collapsed: isCollapsed,
|
||||
onToggle: () => onToggle(group.key),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!isCollapsed) {
|
||||
final entries = group.entries;
|
||||
slivers.add(
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(padding.left, 0, padding.right, 20),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.crossAxisExtent;
|
||||
final columnCount = cardColumnsFor(
|
||||
availableWidth,
|
||||
minCardWidth: kRichCardMinWidth,
|
||||
);
|
||||
final gapTotalWidth = kCardGap * (columnCount - 1);
|
||||
final cardWidth = (availableWidth - gapTotalWidth) / columnCount;
|
||||
|
||||
return SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columnCount,
|
||||
mainAxisExtent: AggregatedMediaCard.height,
|
||||
crossAxisSpacing: kCardGap,
|
||||
mainAxisSpacing: kCardGap,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => SizedBox(
|
||||
width: cardWidth,
|
||||
child: cardBuilder(entries[index]),
|
||||
),
|
||||
childCount: entries.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return slivers;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/emby_ticks.dart';
|
||||
import '../../../shared/widgets/app_card.dart';
|
||||
import '../../../shared/widgets/card_progress_bar.dart';
|
||||
import '../../../shared/widgets/metadata_chip.dart';
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
|
||||
|
||||
class AggregatedMediaCard extends StatefulWidget {
|
||||
final EmbyRawItem item;
|
||||
final String? imageUrl;
|
||||
final String? fallbackImageUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String serverName;
|
||||
final int serverSourceCount;
|
||||
final String? timeLabel;
|
||||
|
||||
final String? networkName;
|
||||
final String? networkLogoUrl;
|
||||
|
||||
final bool showProgress;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onServerSourcesTap;
|
||||
final List<FItem>? contextMenuItems;
|
||||
|
||||
const AggregatedMediaCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.imageUrl,
|
||||
required this.title,
|
||||
required this.serverName,
|
||||
this.serverSourceCount = 1,
|
||||
this.fallbackImageUrl,
|
||||
this.imageHeaders,
|
||||
this.subtitle,
|
||||
this.timeLabel,
|
||||
this.networkName,
|
||||
this.networkLogoUrl,
|
||||
this.showProgress = false,
|
||||
this.onTap,
|
||||
this.onServerSourcesTap,
|
||||
this.contextMenuItems,
|
||||
}) : assert(serverSourceCount > 0);
|
||||
|
||||
static const double height = 84;
|
||||
static const double _thumbWidth = 124;
|
||||
|
||||
@override
|
||||
State<AggregatedMediaCard> createState() => _AggregatedMediaCardState();
|
||||
}
|
||||
|
||||
class _AggregatedMediaCardState extends State<AggregatedMediaCard> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final radius = tokens.radiusCard;
|
||||
final progress = widget.showProgress ? playbackProgress(widget.item) : 0.0;
|
||||
final remaining = widget.showProgress
|
||||
? formatRemainingLabel(widget.item)
|
||||
: null;
|
||||
|
||||
return AppCard(
|
||||
onTap: widget.onTap,
|
||||
contextMenuItems: widget.contextMenuItems,
|
||||
radius: radius,
|
||||
padding: EdgeInsets.zero,
|
||||
onHoverChanged: (h) => setState(() => _hovered = h),
|
||||
child: SizedBox(
|
||||
height: AggregatedMediaCard.height,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_thumbnail(radius, progress),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 10, 8),
|
||||
child: _info(theme, tokens, remaining),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _thumbnail(double radius, double progress) {
|
||||
return SizedBox(
|
||||
width: AggregatedMediaCard._thumbWidth,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SmartImage(
|
||||
url: widget.imageUrl,
|
||||
fallbackUrls: widget.fallbackImageUrl != null
|
||||
? [widget.fallbackImageUrl!]
|
||||
: const [],
|
||||
httpHeaders: widget.imageHeaders,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
),
|
||||
if (_hovered)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.32),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 30,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (progress > 0) CardProgressBar(progress: progress),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _networkBadge(AppTokens tokens) {
|
||||
if (widget.networkLogoUrl != null) {
|
||||
return SizedBox(
|
||||
height: 16,
|
||||
child: SmartImage(
|
||||
url: widget.networkLogoUrl,
|
||||
fit: BoxFit.contain,
|
||||
borderRadius: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
return MetadataChip(
|
||||
label: widget.networkName!,
|
||||
dense: true,
|
||||
foreground: tokens.mutedForeground,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _serverBadge() {
|
||||
final hasMultipleSources = widget.serverSourceCount > 1;
|
||||
if (!hasMultipleSources || widget.onServerSourcesTap == null) {
|
||||
return MetadataChip(label: widget.serverName, dense: true);
|
||||
}
|
||||
|
||||
final label = '${widget.serverName} ${widget.serverSourceCount}服';
|
||||
return Tooltip(
|
||||
message: '选择服务器并查看各自的播放进度',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '$label,选择其他服务器',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onServerSourcesTap,
|
||||
child: MetadataChip(
|
||||
label: label,
|
||||
icon: Icons.keyboard_arrow_down_rounded,
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _info(ThemeData theme, AppTokens tokens, String? remaining) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_serverBadge(),
|
||||
if (widget.networkName != null &&
|
||||
widget.networkName!.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
_networkBadge(tokens),
|
||||
],
|
||||
if (remaining != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
remaining,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (widget.timeLabel != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.timeLabel!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
|
||||
class ServerFilterBar extends StatelessWidget {
|
||||
final List<({String id, String name})> servers;
|
||||
final String? selected;
|
||||
final ValueChanged<String?> onSelect;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const ServerFilterBar({
|
||||
super.key,
|
||||
required this.servers,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
this.padding = const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (servers.length < 2) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_chip(label: '全部', value: null),
|
||||
for (final s in servers) _chip(label: s.name, value: s.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip({required String label, required String? value}) {
|
||||
final active = selected == value;
|
||||
final child = Text(label);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FButton(
|
||||
variant: active ? FButtonVariant.primary : FButtonVariant.ghost,
|
||||
onPress: () => onSelect(value),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/contracts/trakt/trakt_calendar_entry.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/trakt_calendar_provider.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/page_background.dart';
|
||||
import '../../shared/widgets/pill_tab_bar.dart';
|
||||
import '../../shared/widgets/smart_image.dart';
|
||||
|
||||
const _kTabs = ['全部', '剧集', '电影'];
|
||||
|
||||
class TraktCalendarPage extends ConsumerStatefulWidget {
|
||||
const TraktCalendarPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TraktCalendarPage> createState() => _TraktCalendarPageState();
|
||||
}
|
||||
|
||||
class _TraktCalendarPageState extends ConsumerState<TraktCalendarPage> {
|
||||
int _tabIndex = 0;
|
||||
|
||||
void _onTabChanged(int index) {
|
||||
if (index == _tabIndex) return;
|
||||
setState(() => _tabIndex = index);
|
||||
final filter = switch (index) {
|
||||
1 => TraktCalendarFilter.shows,
|
||||
2 => TraktCalendarFilter.movies,
|
||||
_ => TraktCalendarFilter.all,
|
||||
};
|
||||
ref.read(traktCalendarFilterProvider.notifier).state = filter;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncEntries = ref.watch(traktCalendarProvider);
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: PageBackground()),
|
||||
RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(traktCalendarProvider);
|
||||
await ref.read(traktCalendarProvider.future);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
title: '日历',
|
||||
automaticallyImplyLeading: false,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(48),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: PillTabBar(
|
||||
tabs: _kTabs,
|
||||
selectedIndex: _tabIndex,
|
||||
onSelect: _onTabChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
asyncEntries.when(
|
||||
loading: () => const SliverFillRemaining(
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
error: (e, _) => SliverFillRemaining(
|
||||
child: AppErrorState(
|
||||
title: '加载失败',
|
||||
message: '$e',
|
||||
onRetry: () => ref.invalidate(traktCalendarProvider),
|
||||
),
|
||||
),
|
||||
data: (entries) {
|
||||
if (entries.isEmpty) {
|
||||
return const SliverFillRemaining(
|
||||
child: AppErrorState(
|
||||
title: '暂无内容',
|
||||
message: '未来 7 天没有新内容',
|
||||
icon: Icons.calendar_today_outlined,
|
||||
),
|
||||
);
|
||||
}
|
||||
final groups = groupByDate(entries);
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
_buildGroupItem(context, groups, index),
|
||||
childCount: groups.fold<int>(
|
||||
0,
|
||||
(sum, g) => sum + 1 + g.entries.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupItem(
|
||||
BuildContext context,
|
||||
List<CalendarDateGroup> groups,
|
||||
int flatIndex,
|
||||
) {
|
||||
var cursor = 0;
|
||||
for (final group in groups) {
|
||||
if (flatIndex == cursor) {
|
||||
return _DateHeader(label: group.label, count: group.entries.length);
|
||||
}
|
||||
cursor++;
|
||||
final entryIndex = flatIndex - cursor;
|
||||
if (entryIndex < group.entries.length) {
|
||||
return _CalendarEntryTile(entry: group.entries[entryIndex]);
|
||||
}
|
||||
cursor += group.entries.length;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _DateHeader extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
const _DateHeader({required this.label, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CalendarEntryTile extends ConsumerWidget {
|
||||
final TraktCalendarEntry entry;
|
||||
|
||||
const _CalendarEntryTile({required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final hasTmdb = entry.tmdbId != null && entry.tmdbId! > 0;
|
||||
final airTime = _formatAirTime(entry.firstAired.toLocal());
|
||||
|
||||
String? networkName;
|
||||
if (hasTmdb) {
|
||||
final TmdbMediaKey key = (
|
||||
tmdbId: entry.tmdbId!.toString(),
|
||||
mediaType: entry.mediaType,
|
||||
);
|
||||
final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value;
|
||||
networkName = enriched?.networks.firstOrNull?.name;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: hasTmdb
|
||||
? () => goTmdbDetail(
|
||||
context,
|
||||
tmdbId: entry.tmdbId!,
|
||||
mediaType: entry.mediaType,
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 72,
|
||||
child: SmartImage(
|
||||
url: entry.posterUrl,
|
||||
borderRadius: 6,
|
||||
fallbackIcon: entry.type == 'movie'
|
||||
? Icons.movie_outlined
|
||||
: Icons.tv_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
[
|
||||
entry.displaySubtitle,
|
||||
airTime,
|
||||
if (networkName != null) networkName,
|
||||
].where((s) => s.isNotEmpty).join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.55,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!hasTmdb)
|
||||
Icon(
|
||||
Icons.link_off,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.25),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatAirTime(DateTime local) {
|
||||
return '${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../core/contracts/tmdb.dart';
|
||||
import '../../../shared/mappers/tmdb_image_selector.dart';
|
||||
import '../../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import '../utils/detail_helpers.dart';
|
||||
import '../model/detail_hero_model.dart';
|
||||
import '../model/detail_image_model.dart';
|
||||
import '../model/detail_section_model.dart';
|
||||
import '../model/detail_view_state.dart';
|
||||
import '../widgets/tmdb_cast_section.dart';
|
||||
import '../widgets/tmdb_recommendation_section.dart';
|
||||
|
||||
|
||||
DetailViewState buildTmdbDetailViewState({
|
||||
required String mediaType,
|
||||
required TmdbEnrichedDetail? enriched,
|
||||
required TmdbRecommendation? seed,
|
||||
required String imageBaseUrl,
|
||||
required String language,
|
||||
required Widget? heroActions,
|
||||
required List<DetailSection> hitStateSections,
|
||||
required bool showSpinner,
|
||||
}) {
|
||||
final detail = enriched?.detail;
|
||||
|
||||
final title = (detail?.title.isNotEmpty ?? false)
|
||||
? detail!.title
|
||||
: (seed?.title ?? '');
|
||||
final posterPath = detail?.posterPath ?? seed?.posterPath;
|
||||
final backdropPath = detail?.backdropPath ?? seed?.backdropPath;
|
||||
final voteAverage = detail?.voteAverage ?? seed?.voteAverage;
|
||||
final overview = (detail?.overview?.trim().isNotEmpty ?? false)
|
||||
? detail!.overview!.trim()
|
||||
: (seed?.overview?.trim() ?? '');
|
||||
final tagline = detail?.tagline?.trim();
|
||||
|
||||
final metaParts = <String>[];
|
||||
final year = yearOf(detail?.releaseDate);
|
||||
if (year != null) metaParts.add(year);
|
||||
final runtime = formatRuntimeMinutes(detail?.runtime);
|
||||
if (runtime != null) metaParts.add(runtime);
|
||||
|
||||
final genreNames = [
|
||||
for (final g in detail?.genres ?? const <TmdbGenre>[])
|
||||
if (g.name.isNotEmpty) g.name,
|
||||
];
|
||||
|
||||
final cast = enriched?.credits?.cast ?? const <TmdbCastMember>[];
|
||||
final recommendations =
|
||||
enriched?.recommendations?.results ?? const <TmdbRecommendation>[];
|
||||
|
||||
final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath);
|
||||
final backdropUrl = TmdbImageUrl.backdrop(imageBaseUrl, backdropPath);
|
||||
final logoUrl = TmdbImageSelector.logoUrl(
|
||||
enriched?.images,
|
||||
imageBaseUrl,
|
||||
language: language,
|
||||
);
|
||||
final fallbackUrls = TmdbImageSelector.buildFallbackUrls(
|
||||
primaryUrl: backdropUrl,
|
||||
candidates: [posterUrl],
|
||||
);
|
||||
|
||||
final sections = <DetailSection>[
|
||||
...hitStateSections,
|
||||
if (cast.isNotEmpty)
|
||||
DetailSection(
|
||||
id: 'tmdb_cast',
|
||||
child: TmdbCastSection(cast: cast, imageBaseUrl: imageBaseUrl),
|
||||
),
|
||||
if (recommendations.isNotEmpty)
|
||||
DetailSection(
|
||||
id: 'tmdb_recommendations',
|
||||
child: TmdbRecommendationSection(
|
||||
items: recommendations,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
fallbackMediaType: mediaType,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return DetailViewState(
|
||||
image: DetailImageModel(
|
||||
backdropUrl: backdropUrl,
|
||||
fallbackUrls: fallbackUrls,
|
||||
),
|
||||
navTitle: title,
|
||||
hero: DetailHeroModel(
|
||||
title: title,
|
||||
logoUrl: logoUrl,
|
||||
posterUrl: posterUrl,
|
||||
imageHeaders: null,
|
||||
rating: voteAverage,
|
||||
metaParts: metaParts,
|
||||
genres: genreNames,
|
||||
tagline: tagline,
|
||||
overview: overview,
|
||||
actions: heroActions,
|
||||
),
|
||||
sections: sections,
|
||||
showLoadingBelowHero: showSpinner,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
|
||||
|
||||
class AndroidDetailBanner extends StatefulWidget {
|
||||
const AndroidDetailBanner({
|
||||
super.key,
|
||||
required this.scrollProgress,
|
||||
required this.backdropUrl,
|
||||
required this.fallbackUrls,
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
});
|
||||
|
||||
final ValueListenable<double> scrollProgress;
|
||||
final String backdropUrl;
|
||||
final List<String> fallbackUrls;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
@override
|
||||
State<AndroidDetailBanner> createState() => _AndroidDetailBannerState();
|
||||
}
|
||||
|
||||
class _AndroidDetailBannerState extends State<AndroidDetailBanner> {
|
||||
bool _allImagesFailed = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AndroidDetailBanner oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.backdropUrl != widget.backdropUrl ||
|
||||
!listEquals(oldWidget.fallbackUrls, widget.fallbackUrls)) {
|
||||
_allImagesFailed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (!_allImagesFailed)
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0.0, 0.68, 1.0],
|
||||
colors: [Colors.black, Colors.black, Colors.transparent],
|
||||
).createShader(bounds),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: SmartImage(
|
||||
url: widget.backdropUrl,
|
||||
fallbackUrls: widget.fallbackUrls,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.center,
|
||||
borderRadius: 0,
|
||||
placeholder: const SizedBox.expand(),
|
||||
onError: () {
|
||||
if (mounted && !_allImagesFailed) {
|
||||
setState(() => _allImagesFailed = true);
|
||||
}
|
||||
},
|
||||
resolveHttpHeaders: (url) =>
|
||||
widget.embyBaseUrl.isNotEmpty &&
|
||||
url.startsWith(widget.embyBaseUrl)
|
||||
? widget.imageHeaders
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 120,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.38),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: widget.scrollProgress,
|
||||
builder: (context, rawProgress, child) {
|
||||
final progress = rawProgress.clamp(0.0, 1.0);
|
||||
return IgnorePointer(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: progress * 0.5),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class AndroidDetailColors {
|
||||
AndroidDetailColors._();
|
||||
|
||||
static const background = Color(0xFF1A1A2E);
|
||||
static const navBarBg = Color(0xFF141428);
|
||||
static const accent = Color(0xFFE5A00D);
|
||||
static const cardBg = Color(0x0AFFFFFF);
|
||||
static const genrePillBg = Color(0x14FFFFFF);
|
||||
static const genrePillBorder = Color(0x14FFFFFF);
|
||||
static const divider = Color(0x1AFFFFFF);
|
||||
static const starEmpty = Color(0x4DFFFFFF);
|
||||
static const mediaTagBg = Color(0x1AFFFFFF);
|
||||
static const mediaTagBorder = Color(0x1AFFFFFF);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const double androidDetailDesktopTitleBarHeight = 38.0;
|
||||
const double androidDetailNavigationContentHeight = 30.0;
|
||||
|
||||
double computeAndroidDetailNavigationHeight(BuildContext context) {
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
return math.max(topInset, androidDetailDesktopTitleBarHeight) +
|
||||
androidDetailNavigationContentHeight;
|
||||
}
|
||||
|
||||
double computeAndroidDetailBannerHeight(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final bodyHeight = mediaQuery.size.height - mediaQuery.padding.top;
|
||||
final preferredHeight = (bodyHeight * 0.46).clamp(300.0, 390.0);
|
||||
final landscapeSafeHeight = bodyHeight * 0.55;
|
||||
|
||||
return math.min(preferredHeight, landscapeSafeHeight);
|
||||
}
|
||||
|
||||
double computeAndroidDetailCollapseExtent(BuildContext context) {
|
||||
final bannerHeight = computeAndroidDetailBannerHeight(context);
|
||||
final navigationHeight = computeAndroidDetailNavigationHeight(context);
|
||||
return math.max(1.0, bannerHeight - navigationHeight);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
import '../widgets/detail_nav_bar.dart';
|
||||
import 'android_detail_banner.dart';
|
||||
import 'android_detail_colors.dart';
|
||||
import 'android_detail_geometry.dart';
|
||||
|
||||
|
||||
class AndroidDetailLayout extends StatelessWidget {
|
||||
const AndroidDetailLayout({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.scrollProgress,
|
||||
this.backdropUrl,
|
||||
this.fallbackUrls = const [],
|
||||
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
this.backgroundColor,
|
||||
this.navigationColor,
|
||||
required this.title,
|
||||
required this.onBack,
|
||||
required this.heroInfo,
|
||||
this.heroActions,
|
||||
this.overview,
|
||||
this.belowOverview,
|
||||
this.sections = const [],
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final ValueNotifier<double> scrollProgress;
|
||||
|
||||
final String? backdropUrl;
|
||||
final List<String> fallbackUrls;
|
||||
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final Color? backgroundColor;
|
||||
final Color? navigationColor;
|
||||
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
|
||||
final Widget heroInfo;
|
||||
final Widget? heroActions;
|
||||
final Widget? overview;
|
||||
|
||||
|
||||
final Widget? belowOverview;
|
||||
|
||||
|
||||
final List<Widget> sections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
final bannerHeight = computeAndroidDetailBannerHeight(context);
|
||||
final backdrop =
|
||||
backdropUrl ?? (fallbackUrls.isNotEmpty ? fallbackUrls.first : null);
|
||||
final effectiveFallbackUrls = backdropUrl != null
|
||||
? fallbackUrls
|
||||
: fallbackUrls.skip(1).toList(growable: false);
|
||||
|
||||
final targetBackgroundColor =
|
||||
backgroundColor ?? AndroidDetailColors.background;
|
||||
final animationDuration = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350);
|
||||
|
||||
return TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(
|
||||
begin: AndroidDetailColors.background,
|
||||
end: targetBackgroundColor,
|
||||
),
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, animatedBackgroundColor, child) {
|
||||
final effectiveBackgroundColor =
|
||||
animatedBackgroundColor ?? targetBackgroundColor;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: effectiveBackgroundColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _AndroidDetailBlurredBackdrop(
|
||||
imageUrl: backdrop,
|
||||
fallbackUrls: effectiveFallbackUrls,
|
||||
embyBaseUrl: embyBaseUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
),
|
||||
),
|
||||
CustomScrollView(
|
||||
controller: scrollController,
|
||||
physics: const BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
if (backdrop != null)
|
||||
SliverAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
primary: false,
|
||||
pinned: false,
|
||||
stretch: true,
|
||||
toolbarHeight: 0,
|
||||
collapsedHeight: 0,
|
||||
expandedHeight: bannerHeight,
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.parallax,
|
||||
stretchModes: const [StretchMode.zoomBackground],
|
||||
background: AndroidDetailBanner(
|
||||
scrollProgress: scrollProgress,
|
||||
backdropUrl: backdrop,
|
||||
fallbackUrls: effectiveFallbackUrls,
|
||||
embyBaseUrl: embyBaseUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverToBoxAdapter(child: SizedBox(height: topInset + 72)),
|
||||
SliverToBoxAdapter(child: heroInfo),
|
||||
if (heroActions != null)
|
||||
SliverToBoxAdapter(child: heroActions!),
|
||||
if (overview != null) SliverToBoxAdapter(child: overview!),
|
||||
if (belowOverview != null)
|
||||
SliverToBoxAdapter(child: belowOverview!),
|
||||
SliverList.list(
|
||||
children: [
|
||||
...sections,
|
||||
SizedBox(
|
||||
height: 32 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
DetailNavBar(
|
||||
scrollProgress: scrollProgress,
|
||||
title: title,
|
||||
onBack: onBack,
|
||||
chromeColor: navigationColor ?? AndroidDetailColors.navBarBg,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _AndroidDetailBlurredBackdrop extends StatelessWidget {
|
||||
const _AndroidDetailBlurredBackdrop({
|
||||
required this.imageUrl,
|
||||
required this.fallbackUrls,
|
||||
required this.embyBaseUrl,
|
||||
required this.imageHeaders,
|
||||
});
|
||||
|
||||
final String? imageUrl;
|
||||
final List<String> fallbackUrls;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
static const double _blurSigma = 32;
|
||||
|
||||
|
||||
static const LinearGradient _scrim = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0.0, 0.4, 1.0],
|
||||
colors: [Color(0x59000000), Color(0x8C000000), Color(0xD9000000)],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = imageUrl;
|
||||
|
||||
final Widget child;
|
||||
if (url == null) {
|
||||
child = const SizedBox.expand(key: ValueKey<String>('__no_backdrop__'));
|
||||
} else {
|
||||
child = RepaintBoundary(
|
||||
key: ValueKey<String>(url),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: _blurSigma,
|
||||
sigmaY: _blurSigma,
|
||||
tileMode: TileMode.clamp,
|
||||
),
|
||||
child: SmartImage(
|
||||
url: url,
|
||||
fallbackUrls: fallbackUrls,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
memCacheWidth: 400,
|
||||
fallbackIcon: null,
|
||||
placeholder: const SizedBox.expand(),
|
||||
resolveHttpHeaders: (candidateUrl) =>
|
||||
embyBaseUrl.isNotEmpty &&
|
||||
candidateUrl.startsWith(embyBaseUrl)
|
||||
? imageHeaders
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const DecoratedBox(decoration: BoxDecoration(gradient: _scrim)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeOut,
|
||||
layoutBuilder: (currentChild, previousChildren) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [...previousChildren, if (currentChild != null) currentChild],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../widgets/section_container.dart';
|
||||
|
||||
class ExternalLinksSection extends StatelessWidget {
|
||||
final String? imdbId;
|
||||
final int? tmdbId;
|
||||
final String? tmdbMediaType;
|
||||
final int? traktId;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const ExternalLinksSection({
|
||||
super.key,
|
||||
this.imdbId,
|
||||
this.tmdbId,
|
||||
this.tmdbMediaType,
|
||||
this.traktId,
|
||||
this.embedded = true,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
bool get _hasAnyLink => imdbId != null || tmdbId != null || traktId != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_hasAnyLink) return const SizedBox.shrink();
|
||||
return SectionContainer(
|
||||
title: '外部链接',
|
||||
embedded: embedded,
|
||||
leading: leading,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (imdbId != null)
|
||||
_LinkPill(
|
||||
label: 'IMDb',
|
||||
dotColor: const Color(0xFFF5C518),
|
||||
onTap: () =>
|
||||
launchUrl(Uri.parse('https://www.imdb.com/title/$imdbId')),
|
||||
),
|
||||
if (tmdbId != null)
|
||||
_LinkPill(
|
||||
label: 'TMDB',
|
||||
dotColor: const Color(0xFF01B4E4),
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse(
|
||||
'https://www.themoviedb.org/${tmdbMediaType ?? 'movie'}/$tmdbId',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (traktId != null)
|
||||
_LinkPill(
|
||||
label: 'Trakt',
|
||||
dotColor: const Color(0xFFED1C24),
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse(
|
||||
'https://trakt.tv/${tmdbMediaType == 'tv' ? 'shows' : 'movies'}/$traktId',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkPill extends StatelessWidget {
|
||||
final String label;
|
||||
final Color dotColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LinkPill({
|
||||
required this.label,
|
||||
required this.dotColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'android_detail_colors.dart';
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
|
||||
class AndroidHeroActions extends StatelessWidget {
|
||||
final String playLabel;
|
||||
final String? playTrailingLabel;
|
||||
final double? progressPercent;
|
||||
final bool isLoading;
|
||||
final String loadingLabel;
|
||||
final bool canPlay;
|
||||
final VoidCallback? onPlay;
|
||||
final bool showReplay;
|
||||
final VoidCallback? onReplay;
|
||||
final bool isPlayed;
|
||||
final VoidCallback onTogglePlayed;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onToggleFavorite;
|
||||
|
||||
const AndroidHeroActions({
|
||||
super.key,
|
||||
required this.playLabel,
|
||||
this.playTrailingLabel,
|
||||
this.progressPercent,
|
||||
this.isLoading = false,
|
||||
this.loadingLabel = '启动中',
|
||||
this.canPlay = true,
|
||||
this.onPlay,
|
||||
this.showReplay = false,
|
||||
this.onReplay,
|
||||
required this.isPlayed,
|
||||
required this.onTogglePlayed,
|
||||
required this.isFavorite,
|
||||
required this.onToggleFavorite,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (canPlay)
|
||||
Expanded(
|
||||
child: _GoldPlayButton(
|
||||
label: playLabel,
|
||||
trailingLabel: playTrailingLabel,
|
||||
progressPercent: progressPercent,
|
||||
isLoading: isLoading,
|
||||
loadingLabel: loadingLabel,
|
||||
onPressed: onPlay,
|
||||
),
|
||||
),
|
||||
if (canPlay) const SizedBox(width: 8),
|
||||
if (!canPlay) const Spacer(),
|
||||
_SquareButton(
|
||||
icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline,
|
||||
active: isPlayed,
|
||||
onTap: onTogglePlayed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SquareButton(
|
||||
icon: isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
active: isFavorite,
|
||||
onTap: onToggleFavorite,
|
||||
),
|
||||
if (showReplay) ...[
|
||||
const SizedBox(width: 8),
|
||||
_SquareButton(icon: Icons.replay, active: false, onTap: onReplay),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoldPlayButton extends StatelessWidget {
|
||||
final String label;
|
||||
final String? trailingLabel;
|
||||
final bool isLoading;
|
||||
final String loadingLabel;
|
||||
final double? progressPercent;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _GoldPlayButton({
|
||||
required this.label,
|
||||
this.trailingLabel,
|
||||
this.isLoading = false,
|
||||
this.loadingLabel = '启动中',
|
||||
this.progressPercent,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = progressPercent;
|
||||
final normalizedProgress = progress != null && progress > 0
|
||||
? progress.clamp(0.0, 100.0) / 100.0
|
||||
: null;
|
||||
final enabled = !isLoading && onPressed != null;
|
||||
final radius = BorderRadius.circular(10);
|
||||
final backgroundColor = enabled
|
||||
? AndroidDetailColors.accent
|
||||
: Colors.white.withValues(alpha: 0.12);
|
||||
final foregroundColor = enabled
|
||||
? Colors.black
|
||||
: Colors.white.withValues(alpha: 0.38);
|
||||
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: radius,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (normalizedProgress != null)
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: normalizedProgress,
|
||||
child: ColoredBox(
|
||||
color: AndroidDetailColors.accent.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLoading)
|
||||
AppLoadingRing(size: 16, color: foregroundColor)
|
||||
else
|
||||
Icon(Icons.play_arrow, color: foregroundColor, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isLoading
|
||||
? loadingLabel
|
||||
: trailingLabel != null
|
||||
? '$label $trailingLabel'
|
||||
: label,
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SquareButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool active;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SquareButton({required this.icon, required this.active, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = active
|
||||
? AndroidDetailColors.accent
|
||||
: Colors.white.withValues(alpha: 0.8);
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTap,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Center(child: Icon(icon, size: 20, color: color)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
import 'android_detail_colors.dart';
|
||||
|
||||
class AndroidHeroInfo extends StatelessWidget {
|
||||
final String? posterUrl;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final String title;
|
||||
final double? rating;
|
||||
final List<String> metaParts;
|
||||
final List<String> genres;
|
||||
final List<String> mediaInfoTags;
|
||||
|
||||
const AndroidHeroInfo({
|
||||
super.key,
|
||||
this.posterUrl,
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
required this.title,
|
||||
this.rating,
|
||||
this.metaParts = const [],
|
||||
this.genres = const [],
|
||||
this.mediaInfoTags = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x80000000),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
height: 180,
|
||||
child: SmartImage(
|
||||
url: posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 10,
|
||||
placeholder: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A2A4A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
resolveHttpHeaders: (url) =>
|
||||
embyBaseUrl.isNotEmpty && url.startsWith(embyBaseUrl)
|
||||
? imageHeaders
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
height: 1.2,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color(0xCC000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (metaParts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
metaParts.join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Color(0xCC000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (rating != null && rating! > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: _StarRating(rating: rating!),
|
||||
),
|
||||
if (genres.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: genres
|
||||
.map((g) => _GenrePill(label: g))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
if (mediaInfoTags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: mediaInfoTags
|
||||
.map((t) => _MediaInfoPill(label: t))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StarRating extends StatelessWidget {
|
||||
final double rating;
|
||||
const _StarRating({required this.rating});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stars = rating / 2.0;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < 5; i++)
|
||||
Icon(
|
||||
Icons.star_rounded,
|
||||
size: 16,
|
||||
color: i < stars.round()
|
||||
? AndroidDetailColors.accent
|
||||
: AndroidDetailColors.starEmpty,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
rating.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Color(0xCC000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenrePill extends StatelessWidget {
|
||||
final String label;
|
||||
const _GenrePill({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AndroidDetailColors.genrePillBg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AndroidDetailColors.genrePillBorder),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaInfoPill extends StatelessWidget {
|
||||
final String label;
|
||||
const _MediaInfoPill({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AndroidDetailColors.mediaTagBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AndroidDetailColors.mediaTagBorder),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
|
||||
class AndroidMediaInfoSection extends StatelessWidget {
|
||||
final EmbyRawMediaSource? source;
|
||||
final List<String> studioNames;
|
||||
final List<String> directors;
|
||||
final Widget? leading;
|
||||
|
||||
const AndroidMediaInfoSection({
|
||||
super.key,
|
||||
this.source,
|
||||
this.studioNames = const [],
|
||||
this.directors = const [],
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = _buildRows();
|
||||
if (rows.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: rows,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildRows() {
|
||||
final src = source;
|
||||
final streams = src?.MediaStreams ?? [];
|
||||
final rows = <Widget>[];
|
||||
|
||||
final subCount = streams.where((s) => s.Type == 'Subtitle').length;
|
||||
if (subCount > 0) {
|
||||
_addRow(rows, '字幕', '$subCount 条字幕轨');
|
||||
}
|
||||
|
||||
if (directors.isNotEmpty) {
|
||||
_addRow(rows, '导演', directors.join(' · '));
|
||||
}
|
||||
|
||||
if (studioNames.isNotEmpty) {
|
||||
_addRow(rows, '工作室', studioNames.join(' · '));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _addRow(List<Widget> rows, String label, String value) {
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(Divider(color: Colors.white.withValues(alpha: 0.04), height: 1));
|
||||
}
|
||||
rows.add(_InfoRow(label: label, value: value));
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/app_dialog.dart';
|
||||
import 'android_detail_colors.dart';
|
||||
|
||||
class AndroidOverviewSection extends StatelessWidget {
|
||||
final String overview;
|
||||
|
||||
const AndroidOverviewSection({super.key, required this.overview});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
height: 1.65,
|
||||
);
|
||||
final textSpan = TextSpan(text: overview, style: textStyle);
|
||||
final tp = TextPainter(
|
||||
text: textSpan,
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final hasOverflow = tp.didExceedMaxLines;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: hasOverflow ? () => _showOverviewDialog(context) : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
overview,
|
||||
style: textStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasOverflow) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'阅读全部',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AndroidDetailColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOverviewDialog(BuildContext context) {
|
||||
showAppRawDialog<void>(
|
||||
context,
|
||||
builder: (context) => ColoredBox(
|
||||
color: const Color(0xFF1E1E2E),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'简介',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
overview,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import 'android_detail_colors.dart';
|
||||
|
||||
|
||||
class AndroidSectionDivider extends StatelessWidget {
|
||||
const AndroidSectionDivider({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FDivider(style: .delta(color: AndroidDetailColors.divider)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/widgets/auto_dismiss_menu.dart';
|
||||
import '../widgets/stream_selector_actions.dart';
|
||||
|
||||
const _dropdownMenuMaxHeight = 320.0;
|
||||
|
||||
|
||||
class AndroidStreamSelectorRow extends StatelessWidget {
|
||||
|
||||
final List<EmbyRawMediaSource> sources;
|
||||
final int selectedSourceIdx;
|
||||
final ValueChanged<int>? onSelectSourceIndex;
|
||||
|
||||
final List<EmbyRawMediaStream> subtitles;
|
||||
final List<EmbyRawMediaStream> audios;
|
||||
final int? selectedSubtitleIdx;
|
||||
final int selectedAudioIdx;
|
||||
final ValueChanged<int?> onSelectSubtitleIndex;
|
||||
final ValueChanged<int> onSelectAudioIndex;
|
||||
|
||||
const AndroidStreamSelectorRow({
|
||||
super.key,
|
||||
this.sources = const [],
|
||||
this.selectedSourceIdx = 0,
|
||||
this.onSelectSourceIndex,
|
||||
required this.subtitles,
|
||||
required this.audios,
|
||||
required this.selectedSubtitleIdx,
|
||||
required this.selectedAudioIdx,
|
||||
required this.onSelectSubtitleIndex,
|
||||
required this.onSelectAudioIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final buttons = <Widget>[];
|
||||
|
||||
final onSelectSource = onSelectSourceIndex;
|
||||
if (sources.length > 1 && onSelectSource != null) {
|
||||
buttons.add(
|
||||
_SelectorButton(
|
||||
icon: Icons.video_file_outlined,
|
||||
label: versionSummaryLabel(sources, selectedSourceIdx),
|
||||
menuChildrenBuilder: () => buildVersionMenuButtons(
|
||||
sources: sources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
onSelectSource: onSelectSource,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (subtitles.length > 1) {
|
||||
buttons.add(
|
||||
_SelectorButton(
|
||||
icon: Icons.subtitles_outlined,
|
||||
label: subtitleSummaryLabel(subtitles, selectedSubtitleIdx),
|
||||
menuChildrenBuilder: () => buildSubtitleMenuButtons(
|
||||
subtitles: subtitles,
|
||||
selectedSubtitleIdx: selectedSubtitleIdx,
|
||||
onSelectSubtitle: onSelectSubtitleIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (audios.length > 1) {
|
||||
buttons.add(
|
||||
_SelectorButton(
|
||||
icon: Icons.audiotrack_outlined,
|
||||
label: audioSummaryLabel(audios, selectedAudioIdx),
|
||||
menuChildrenBuilder: () => buildAudioMenuButtons(
|
||||
audios: audios,
|
||||
selectedAudioIdx: selectedAudioIdx,
|
||||
onSelectAudio: onSelectAudioIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (buttons.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: buttons.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, index) => buttons[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectorButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final List<FItem> Function() menuChildrenBuilder;
|
||||
|
||||
const _SelectorButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.menuChildrenBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FPopoverMenu(
|
||||
menuBuilder: autoDismissMenuBuilder,
|
||||
menuAnchor: Alignment.topCenter,
|
||||
childAnchor: Alignment.bottomCenter,
|
||||
maxHeight: _dropdownMenuMaxHeight,
|
||||
menu: [FItemGroup(children: menuChildrenBuilder())],
|
||||
builder: (context, controller, child) => GestureDetector(
|
||||
onTap: controller.toggle,
|
||||
child: child,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 140),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 18,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../../core/contracts/tmdb.dart';
|
||||
|
||||
|
||||
class TmdbDetailEntry {
|
||||
final String mediaType;
|
||||
final String tmdbId;
|
||||
final TmdbRecommendation? seed;
|
||||
|
||||
const TmdbDetailEntry({
|
||||
required this.mediaType,
|
||||
required this.tmdbId,
|
||||
this.seed,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/contracts/auth.dart';
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/detail_palette_provider.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/tmdb_settings_provider.dart';
|
||||
import '../../providers/version_priority_provider.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/mappers/tmdb_image_selector.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import 'android/android_detail_layout.dart';
|
||||
import 'android/android_detail_colors.dart';
|
||||
import 'android/android_external_links_section.dart';
|
||||
import 'android/android_hero_actions.dart';
|
||||
import 'android/android_hero_info.dart';
|
||||
import 'android/android_overview_section.dart';
|
||||
import 'android/android_section_divider.dart';
|
||||
import 'android/android_stream_selector_row.dart';
|
||||
import 'emby_detail_helpers.dart';
|
||||
import 'emby_detail_sections_builder.dart';
|
||||
import 'model/stream_selection.dart';
|
||||
|
||||
class EmbyDetailBodyAndroid extends ConsumerWidget {
|
||||
const EmbyDetailBodyAndroid({
|
||||
super.key,
|
||||
required this.snap,
|
||||
required this.detailData,
|
||||
required this.seedItem,
|
||||
required this.selectedItem,
|
||||
required this.tmdbSeriesItem,
|
||||
required this.detailServerId,
|
||||
required this.initialSeasonId,
|
||||
required this.pendingMediaSourceId,
|
||||
required this.scrollController,
|
||||
required this.scrollProgress,
|
||||
required this.imageHeaders,
|
||||
required this.session,
|
||||
required this.priorities,
|
||||
required this.selectedSourceIdx,
|
||||
required this.selectedSubtitleIdx,
|
||||
required this.selectedAudioIdx,
|
||||
required this.selectedCrossServerCard,
|
||||
required this.isPlayed,
|
||||
required this.isFavorite,
|
||||
required this.isLaunchingPlayer,
|
||||
required this.onBack,
|
||||
required this.onRetry,
|
||||
required this.onTogglePlayed,
|
||||
required this.onToggleFavorite,
|
||||
required this.onSelectSourceIndex,
|
||||
required this.onSelectSubtitleIndex,
|
||||
required this.onSelectAudioIndex,
|
||||
required this.onEpisodeTap,
|
||||
required this.onSelectCrossServerSource,
|
||||
required this.onApplyPendingMediaSource,
|
||||
required this.onClearPendingMediaSource,
|
||||
required this.onLaunchPlayer,
|
||||
});
|
||||
|
||||
final AsyncSnapshot<MediaDetailRes> snap;
|
||||
final MediaDetailRes? detailData;
|
||||
final EmbyRawItem? seedItem;
|
||||
final EmbyRawItem? selectedItem;
|
||||
final EmbyRawItem? tmdbSeriesItem;
|
||||
final String? detailServerId;
|
||||
final String? initialSeasonId;
|
||||
final String? pendingMediaSourceId;
|
||||
final ScrollController scrollController;
|
||||
final ValueNotifier<double> scrollProgress;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final AuthedSession session;
|
||||
final List<VersionPriority> priorities;
|
||||
final int selectedSourceIdx;
|
||||
final int? selectedSubtitleIdx;
|
||||
final int selectedAudioIdx;
|
||||
final CrossServerSourceCard? selectedCrossServerCard;
|
||||
final bool isPlayed;
|
||||
final bool isFavorite;
|
||||
final bool isLaunchingPlayer;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onTogglePlayed;
|
||||
final VoidCallback onToggleFavorite;
|
||||
final ValueChanged<int> onSelectSourceIndex;
|
||||
final ValueChanged<int?> onSelectSubtitleIndex;
|
||||
final ValueChanged<int> onSelectAudioIndex;
|
||||
final ValueChanged<EmbyRawItem> onEpisodeTap;
|
||||
final ValueChanged<CrossServerSourceCard> onSelectCrossServerSource;
|
||||
final ValueChanged<int> onApplyPendingMediaSource;
|
||||
final VoidCallback onClearPendingMediaSource;
|
||||
final Future<void> Function(
|
||||
EmbyRawItem displayItem,
|
||||
List<EmbyRawMediaSource> sources, {
|
||||
bool fromStart,
|
||||
int? overrideSubtitleIdx,
|
||||
String? backdropUrl,
|
||||
})
|
||||
onLaunchPlayer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (snap.connectionState != ConnectionState.done &&
|
||||
detailData == null &&
|
||||
seedItem == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
),
|
||||
),
|
||||
body: const Center(child: AppLoadingRing()),
|
||||
);
|
||||
}
|
||||
|
||||
final data = detailData ?? snap.data;
|
||||
if (snap.hasError && data == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
),
|
||||
),
|
||||
body: AppErrorState(
|
||||
message: formatUserError(snap.error, fallback: '详情加载失败'),
|
||||
onRetry: onRetry,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final baseItem = data?.base ?? seedItem;
|
||||
if (baseItem == null) return const Center(child: Text('未找到数据'));
|
||||
final isSeedOnly = data == null;
|
||||
final displayItem = selectedItem ?? baseItem;
|
||||
|
||||
final tmdbMediaRef = tmdbMediaRefFor(baseItem, tmdbSeriesItem);
|
||||
final tmdbSettings = tmdbMediaRef != null
|
||||
? ref.watch(tmdbSettingsProvider).value
|
||||
: null;
|
||||
final tmdbImageSet =
|
||||
tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
|
||||
? ref.watch(
|
||||
tmdbImagesProvider((
|
||||
tmdbId: tmdbMediaRef.id,
|
||||
mediaType: tmdbMediaRef.mediaType,
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
final tmdbImageBaseUrl = tmdbSettings?.effectiveImageBaseUrl;
|
||||
final tmdbDetail =
|
||||
tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
|
||||
? ref.watch(
|
||||
tmdbMediaDetailProvider((
|
||||
tmdbId: tmdbMediaRef.id,
|
||||
mediaType: tmdbMediaRef.mediaType,
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
|
||||
final tmdbBackdropUrl = TmdbImageSelector.backdropUrl(
|
||||
tmdbImageSet,
|
||||
tmdbImageBaseUrl,
|
||||
);
|
||||
final tmdbPosterUrl = TmdbImageSelector.posterUrl(
|
||||
tmdbImageSet,
|
||||
tmdbImageBaseUrl,
|
||||
);
|
||||
|
||||
final embyBackdropUrl = EmbyImageUrl.detailBanner(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: baseItem,
|
||||
);
|
||||
|
||||
final String? embyPosterUrl;
|
||||
if (baseItem.Type == 'Episode' &&
|
||||
(baseItem.SeriesId?.isNotEmpty ?? false)) {
|
||||
embyPosterUrl = EmbyImageUrl.primaryById(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
itemId: baseItem.SeriesId!,
|
||||
maxHeight: 720,
|
||||
);
|
||||
} else {
|
||||
embyPosterUrl = EmbyImageUrl.primary(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: baseItem,
|
||||
maxHeight: 720,
|
||||
);
|
||||
}
|
||||
|
||||
final backdropUrl = tmdbBackdropUrl ?? embyBackdropUrl;
|
||||
final posterUrl = tmdbPosterUrl ?? embyPosterUrl;
|
||||
final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls(
|
||||
primaryUrl: backdropUrl,
|
||||
candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl],
|
||||
);
|
||||
final paletteImageUrl = posterUrl ?? backdropUrl;
|
||||
final paletteImageHeaders = paletteImageUrl.startsWith(session.serverUrl)
|
||||
? imageHeaders
|
||||
: null;
|
||||
final extractedBackgroundColor = ref
|
||||
.watch(
|
||||
detailPaletteProvider(
|
||||
DetailPaletteRequest(
|
||||
url: paletteImageUrl,
|
||||
headers: paletteImageHeaders,
|
||||
),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
final detailBackgroundColor =
|
||||
extractedBackgroundColor ?? AndroidDetailColors.background;
|
||||
|
||||
final canPlay = displayItem.Type != 'Series';
|
||||
final rawSources = mediaSourcesOfItem(displayItem);
|
||||
final sortedSources = sortMediaSources(rawSources, priorities);
|
||||
|
||||
final pendingId = pendingMediaSourceId;
|
||||
if (pendingId != null && sortedSources.isNotEmpty) {
|
||||
final idx = resolveDefaultSourceIndex(
|
||||
sortedSources,
|
||||
pendingId,
|
||||
fallback: -1,
|
||||
);
|
||||
if (idx >= 0 && idx != selectedSourceIdx) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onApplyPendingMediaSource(idx);
|
||||
});
|
||||
} else {
|
||||
onClearPendingMediaSource();
|
||||
}
|
||||
}
|
||||
|
||||
final selectedSrc = resolveActiveMediaSource(
|
||||
selectedCrossServerCard,
|
||||
sortedSources,
|
||||
selectedSourceIdx,
|
||||
);
|
||||
final int? effectiveSubtitleIdx = selectedSubtitleIdx;
|
||||
final streamSelection = resolveStreamSelection(
|
||||
selectedSrc,
|
||||
selectedSubtitleIdx,
|
||||
selectedAudioIdx,
|
||||
);
|
||||
|
||||
final String? seriesId;
|
||||
if (baseItem.Type == 'Series') {
|
||||
seriesId = baseItem.Id;
|
||||
} else if (baseItem.Type == 'Episode' &&
|
||||
(baseItem.SeriesId?.isNotEmpty ?? false)) {
|
||||
seriesId = baseItem.SeriesId;
|
||||
} else {
|
||||
seriesId = null;
|
||||
}
|
||||
|
||||
final similarItemId =
|
||||
baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)
|
||||
? baseItem.SeriesId!
|
||||
: baseItem.Id;
|
||||
|
||||
final frozenOverview = baseItem.Overview;
|
||||
final embyOverview = (frozenOverview ?? displayItem.Overview)?.trim();
|
||||
final overviewFallback =
|
||||
(tmdbDetail?.overview ??
|
||||
tmdbSeriesItem?.Overview ??
|
||||
(baseItem.Type == 'Series' ? baseItem.Overview : null))
|
||||
?.trim();
|
||||
final overview = embyOverview != null && embyOverview.isNotEmpty
|
||||
? embyOverview
|
||||
: overviewFallback;
|
||||
|
||||
final metaParts = buildDetailMetaParts(displayItem, source: selectedSrc);
|
||||
final genres = extractGenres(baseItem);
|
||||
|
||||
final progressPercent = detailProgressPercent(displayItem);
|
||||
final hasProgress = progressPercent != null && progressPercent > 0;
|
||||
final pos = displayItem.UserData?.PlaybackPositionTicks;
|
||||
final positionClock = pos != null && pos > 0
|
||||
? formatTicksAsClock(pos)
|
||||
: null;
|
||||
|
||||
final studiosRaw = baseItem.extra['Studios'];
|
||||
final studioNames = studiosRaw is List
|
||||
? studiosRaw
|
||||
.whereType<Map>()
|
||||
.map((s) => s['Name']?.toString() ?? '')
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toList()
|
||||
: <String>[];
|
||||
|
||||
final peopleRaw = baseItem.extra['People'];
|
||||
final directors = peopleRaw is List
|
||||
? peopleRaw
|
||||
.whereType<Map>()
|
||||
.where((p) => p['Type'] == 'Director')
|
||||
.map((p) => p['Name']?.toString() ?? '')
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toList()
|
||||
: <String>[];
|
||||
|
||||
final mediaInfoTags = _buildMediaInfoTags(selectedSrc);
|
||||
|
||||
final sections = buildEmbyDetailSections(
|
||||
ref: ref,
|
||||
baseItem: baseItem,
|
||||
displayItem: displayItem,
|
||||
seriesId: seriesId,
|
||||
similarItemId: similarItemId,
|
||||
tmdbMediaRef: tmdbMediaRef,
|
||||
detailServerId: detailServerId,
|
||||
initialSeasonId: initialSeasonId,
|
||||
tmdbSeriesItem: tmdbSeriesItem,
|
||||
sortedSources: sortedSources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
selectedCrossServerCard: selectedCrossServerCard,
|
||||
currentServerName: ref.read(activeSessionProvider)?.serverName,
|
||||
onEpisodeTap: onEpisodeTap,
|
||||
onSelectSourceIndex: onSelectSourceIndex,
|
||||
onSelectCrossServerSource: onSelectCrossServerSource,
|
||||
activeSource: selectedSrc,
|
||||
studioNames: studioNames,
|
||||
directors: directors,
|
||||
sectionLeading: const AndroidSectionDivider(),
|
||||
);
|
||||
if (isSeedOnly) sections.removeRange(1, sections.length);
|
||||
|
||||
final providerIds = providerIdsOfItem(baseItem);
|
||||
final imdbId =
|
||||
providerIds['Imdb'] ?? providerIds['IMDB'] ?? providerIds['imdb'];
|
||||
final tmdbIdRaw =
|
||||
providerIds['Tmdb'] ?? providerIds['TMDB'] ?? providerIds['tmdb'];
|
||||
final tmdbIdInt = tmdbIdRaw != null ? int.tryParse(tmdbIdRaw) : null;
|
||||
final traktRaw =
|
||||
providerIds['TraktTv'] ?? providerIds['Trakt'] ?? providerIds['trakt'];
|
||||
final traktIdInt = traktRaw != null ? int.tryParse(traktRaw) : null;
|
||||
|
||||
final sectionWidgets = <Widget>[
|
||||
for (final section in sections) section.child,
|
||||
];
|
||||
|
||||
sectionWidgets.add(
|
||||
ExternalLinksSection(
|
||||
imdbId: imdbId,
|
||||
tmdbId: tmdbIdInt,
|
||||
tmdbMediaType: tmdbMediaRef?.mediaType,
|
||||
traktId: traktIdInt,
|
||||
leading: const AndroidSectionDivider(),
|
||||
),
|
||||
);
|
||||
|
||||
return AndroidDetailLayout(
|
||||
scrollController: scrollController,
|
||||
scrollProgress: scrollProgress,
|
||||
backdropUrl: backdropUrl,
|
||||
fallbackUrls: backgroundFallbackUrls,
|
||||
embyBaseUrl: session.serverUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
backgroundColor: detailBackgroundColor,
|
||||
navigationColor: createDetailNavigationColor(detailBackgroundColor),
|
||||
title: detailNavTitle(displayItem, tmdbSeriesItem),
|
||||
onBack: onBack,
|
||||
heroInfo: AndroidHeroInfo(
|
||||
posterUrl: posterUrl,
|
||||
embyBaseUrl: session.serverUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
title: displayItem.Name,
|
||||
rating: displayItem.CommunityRating,
|
||||
metaParts: metaParts,
|
||||
genres: genres,
|
||||
mediaInfoTags: mediaInfoTags,
|
||||
),
|
||||
heroActions: isSeedOnly
|
||||
? null
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AndroidHeroActions(
|
||||
playLabel: hasProgress ? '继续播放' : '播放',
|
||||
playTrailingLabel: positionClock,
|
||||
progressPercent: progressPercent,
|
||||
isLoading: isLaunchingPlayer,
|
||||
canPlay: canPlay,
|
||||
onPlay: canPlay
|
||||
? () => unawaited(
|
||||
onLaunchPlayer(
|
||||
displayItem,
|
||||
sortedSources,
|
||||
overrideSubtitleIdx: effectiveSubtitleIdx,
|
||||
backdropUrl: backdropUrl,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
showReplay: hasProgress && canPlay,
|
||||
onReplay: canPlay
|
||||
? () => unawaited(
|
||||
onLaunchPlayer(
|
||||
displayItem,
|
||||
sortedSources,
|
||||
fromStart: true,
|
||||
overrideSubtitleIdx: effectiveSubtitleIdx,
|
||||
backdropUrl: backdropUrl,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
isPlayed: isPlayed,
|
||||
onTogglePlayed: onTogglePlayed,
|
||||
isFavorite: isFavorite,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
),
|
||||
if (canPlay)
|
||||
AndroidStreamSelectorRow(
|
||||
sources: sortedSources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
onSelectSourceIndex: onSelectSourceIndex,
|
||||
subtitles: streamSelection.subtitles,
|
||||
audios: streamSelection.audios,
|
||||
selectedSubtitleIdx: selectedSubtitleIdx,
|
||||
selectedAudioIdx: selectedAudioIdx,
|
||||
onSelectSubtitleIndex: onSelectSubtitleIndex,
|
||||
onSelectAudioIndex: onSelectAudioIndex,
|
||||
),
|
||||
],
|
||||
),
|
||||
overview: overview != null && overview.isNotEmpty
|
||||
? AndroidOverviewSection(overview: overview)
|
||||
: null,
|
||||
belowOverview: null,
|
||||
sections: sectionWidgets,
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _buildMediaInfoTags(EmbyRawMediaSource? source) {
|
||||
if (source == null) return const [];
|
||||
final tags = <String>[];
|
||||
final streams = source.MediaStreams ?? [];
|
||||
|
||||
final video = streams.where((s) => s.Type == 'Video').firstOrNull;
|
||||
if (video != null) {
|
||||
final codec = video.Codec?.toUpperCase();
|
||||
if (codec != null && codec.isNotEmpty) tags.add(codec);
|
||||
final range = video.extra['VideoRange']?.toString();
|
||||
if (range != null && range.isNotEmpty && range != 'SDR') {
|
||||
tags.add(range);
|
||||
}
|
||||
}
|
||||
|
||||
final audio = streams.where((s) => s.Type == 'Audio').firstOrNull;
|
||||
if (audio != null) {
|
||||
final codec = audio.Codec?.toUpperCase();
|
||||
if (codec != null && codec.isNotEmpty) tags.add(codec);
|
||||
}
|
||||
|
||||
if (source.Size != null && source.Size! > 0) {
|
||||
tags.add(formatFileSize(source.Size!));
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/contracts/auth.dart';
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/tmdb_settings_provider.dart';
|
||||
import '../../providers/version_priority_provider.dart';
|
||||
import '../../shared/constants/breakpoints.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/mappers/tmdb_image_selector.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import 'emby_detail_helpers.dart';
|
||||
import 'emby_detail_hero_builder.dart';
|
||||
import 'emby_detail_sections_builder.dart';
|
||||
import 'model/detail_hero_model.dart';
|
||||
import 'model/detail_image_model.dart';
|
||||
import 'model/detail_view_state.dart';
|
||||
import 'model/stream_selection.dart';
|
||||
import 'widgets/detail_immersive_scaffold.dart';
|
||||
import 'widgets/detail_nav_bar.dart';
|
||||
import 'widgets/detail_view_renderer.dart';
|
||||
|
||||
Widget buildEmbyDetailBody({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required AsyncSnapshot<MediaDetailRes> snap,
|
||||
required MediaDetailRes? detailData,
|
||||
required EmbyRawItem? seedItem,
|
||||
required EmbyRawItem? selectedItem,
|
||||
required EmbyRawItem? tmdbSeriesItem,
|
||||
required String? detailServerId,
|
||||
required String? initialSeasonId,
|
||||
required String? pendingMediaSourceId,
|
||||
|
||||
required ScrollController scrollController,
|
||||
required ValueNotifier<double> scrollProgress,
|
||||
required Map<String, String>? imageHeaders,
|
||||
required AuthedSession session,
|
||||
required List<VersionPriority> priorities,
|
||||
required int selectedSourceIdx,
|
||||
required int? selectedSubtitleIdx,
|
||||
required int selectedAudioIdx,
|
||||
required CrossServerSourceCard? selectedCrossServerCard,
|
||||
required bool isPlayed,
|
||||
required bool isFavorite,
|
||||
required bool isLaunchingPlayer,
|
||||
required VoidCallback onBack,
|
||||
required VoidCallback onRetry,
|
||||
required VoidCallback onTogglePlayed,
|
||||
required VoidCallback onToggleFavorite,
|
||||
required ValueChanged<int> onSelectSourceIndex,
|
||||
required ValueChanged<int?> onSelectSubtitleIndex,
|
||||
required ValueChanged<int> onSelectAudioIndex,
|
||||
required ValueChanged<EmbyRawItem> onEpisodeTap,
|
||||
required ValueChanged<CrossServerSourceCard> onSelectCrossServerSource,
|
||||
required ValueChanged<int> onApplyPendingMediaSource,
|
||||
required VoidCallback onClearPendingMediaSource,
|
||||
required Future<void> Function(
|
||||
EmbyRawItem displayItem,
|
||||
List<EmbyRawMediaSource> sources, {
|
||||
bool fromStart,
|
||||
int? overrideSubtitleIdx,
|
||||
String? backdropUrl,
|
||||
})
|
||||
onLaunchPlayer,
|
||||
}) {
|
||||
if (snap.connectionState != ConnectionState.done &&
|
||||
detailData == null &&
|
||||
seedItem == null) {
|
||||
return Stack(
|
||||
children: [
|
||||
const Center(child: AppLoadingRing()),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: DetailNavBar(
|
||||
scrollProgress: scrollProgress,
|
||||
title: '',
|
||||
onBack: onBack,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final data = detailData ?? snap.data;
|
||||
if (snap.hasError && data == null) {
|
||||
return Stack(
|
||||
children: [
|
||||
AppErrorState(
|
||||
message: formatUserError(snap.error, fallback: '详情加载失败'),
|
||||
onRetry: onRetry,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: DetailNavBar(
|
||||
scrollProgress: scrollProgress,
|
||||
title: '',
|
||||
onBack: onBack,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final baseItem = data?.base ?? seedItem;
|
||||
if (baseItem == null) return const Center(child: Text('未找到数据'));
|
||||
final isSeedOnly = data == null;
|
||||
final displayItem = selectedItem ?? baseItem;
|
||||
final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
|
||||
|
||||
final tmdbMediaRef = tmdbMediaRefFor(baseItem, tmdbSeriesItem);
|
||||
final tmdbSettings = tmdbMediaRef != null
|
||||
? ref.watch(tmdbSettingsProvider).value
|
||||
: null;
|
||||
final tmdbImageSet =
|
||||
tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
|
||||
? ref.watch(
|
||||
tmdbImagesProvider((
|
||||
tmdbId: tmdbMediaRef.id,
|
||||
mediaType: tmdbMediaRef.mediaType,
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
final tmdbImageBaseUrl = tmdbSettings?.effectiveImageBaseUrl;
|
||||
final tmdbDetail = tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
|
||||
? ref.watch(
|
||||
tmdbMediaDetailProvider((
|
||||
tmdbId: tmdbMediaRef.id,
|
||||
mediaType: tmdbMediaRef.mediaType,
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
|
||||
final tmdbBackdropUrl = TmdbImageSelector.backdropUrl(
|
||||
tmdbImageSet,
|
||||
tmdbImageBaseUrl,
|
||||
);
|
||||
final tmdbPosterUrl = TmdbImageSelector.posterUrl(
|
||||
tmdbImageSet,
|
||||
tmdbImageBaseUrl,
|
||||
);
|
||||
|
||||
final embyBackdropUrl = EmbyImageUrl.detailBanner(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: baseItem,
|
||||
);
|
||||
|
||||
final String? embyPosterUrl;
|
||||
if (baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)) {
|
||||
embyPosterUrl = EmbyImageUrl.primaryById(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
itemId: baseItem.SeriesId!,
|
||||
maxHeight: 720,
|
||||
);
|
||||
} else {
|
||||
embyPosterUrl = EmbyImageUrl.primary(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: baseItem,
|
||||
maxHeight: 720,
|
||||
);
|
||||
}
|
||||
|
||||
final backdropUrl = tmdbBackdropUrl ?? embyBackdropUrl;
|
||||
final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls(
|
||||
primaryUrl: backdropUrl,
|
||||
candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl],
|
||||
);
|
||||
|
||||
final imageModel = DetailImageModel(
|
||||
backdropUrl: backdropUrl,
|
||||
fallbackUrls: backgroundFallbackUrls,
|
||||
embyBaseUrl: session.serverUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
);
|
||||
|
||||
final isEpisode = baseItem.Type == 'Episode';
|
||||
final logoItemId = isEpisode && (baseItem.SeriesId?.isNotEmpty ?? false)
|
||||
? baseItem.SeriesId!
|
||||
: baseItem.Id;
|
||||
final logoTag = isEpisode ? null : baseItem.ImageTags?['Logo'];
|
||||
final logoUrl = !isEpisode && logoTag == null
|
||||
? null
|
||||
: EmbyImageUrl.logo(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
itemId: logoItemId,
|
||||
tag: logoTag,
|
||||
);
|
||||
|
||||
final canPlay = displayItem.Type != 'Series';
|
||||
final rawSources = mediaSourcesOfItem(displayItem);
|
||||
final sortedSources = sortMediaSources(rawSources, priorities);
|
||||
|
||||
final pendingId = pendingMediaSourceId;
|
||||
if (pendingId != null && sortedSources.isNotEmpty) {
|
||||
final idx = resolveDefaultSourceIndex(
|
||||
sortedSources,
|
||||
pendingId,
|
||||
fallback: -1,
|
||||
);
|
||||
if (idx >= 0 && idx != selectedSourceIdx) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onApplyPendingMediaSource(idx);
|
||||
});
|
||||
} else {
|
||||
onClearPendingMediaSource();
|
||||
}
|
||||
}
|
||||
|
||||
final selectedSrc = resolveActiveMediaSource(
|
||||
selectedCrossServerCard,
|
||||
sortedSources,
|
||||
selectedSourceIdx,
|
||||
);
|
||||
|
||||
final streamSelection = resolveStreamSelection(
|
||||
selectedSrc,
|
||||
selectedSubtitleIdx,
|
||||
selectedAudioIdx,
|
||||
);
|
||||
final subtitles = streamSelection.subtitles;
|
||||
final audios = streamSelection.audios;
|
||||
final int? effectiveSubtitleIdx = selectedSubtitleIdx;
|
||||
|
||||
final String? seriesId;
|
||||
if (baseItem.Type == 'Series') {
|
||||
seriesId = baseItem.Id;
|
||||
} else if (baseItem.Type == 'Episode' &&
|
||||
(baseItem.SeriesId?.isNotEmpty ?? false)) {
|
||||
seriesId = baseItem.SeriesId;
|
||||
} else {
|
||||
seriesId = null;
|
||||
}
|
||||
|
||||
final similarItemId =
|
||||
baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)
|
||||
? baseItem.SeriesId!
|
||||
: baseItem.Id;
|
||||
|
||||
final frozenOverview = baseItem.Overview;
|
||||
final embyOverview = (frozenOverview ?? displayItem.Overview)?.trim();
|
||||
final overviewFallback =
|
||||
(tmdbDetail?.overview ??
|
||||
tmdbSeriesItem?.Overview ??
|
||||
(baseItem.Type == 'Series' ? baseItem.Overview : null))
|
||||
?.trim();
|
||||
final overview = embyOverview != null && embyOverview.isNotEmpty
|
||||
? embyOverview
|
||||
: overviewFallback;
|
||||
|
||||
final heroActions = isSeedOnly
|
||||
? null
|
||||
: buildEmbyHeroActions(
|
||||
displayItem: displayItem,
|
||||
canPlay: canPlay,
|
||||
sortedSources: sortedSources,
|
||||
subtitles: subtitles,
|
||||
audios: audios,
|
||||
effectiveSubtitleIdx: effectiveSubtitleIdx,
|
||||
compact: compact,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
selectedSubtitleIdx: selectedSubtitleIdx,
|
||||
selectedAudioIdx: selectedAudioIdx,
|
||||
isPlayed: isPlayed,
|
||||
isFavorite: isFavorite,
|
||||
isLaunchingPlayer: isLaunchingPlayer,
|
||||
onSelectSource: onSelectSourceIndex,
|
||||
onSelectSubtitle: onSelectSubtitleIndex,
|
||||
onSelectAudio: onSelectAudioIndex,
|
||||
onTogglePlayed: onTogglePlayed,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onReplay: () => unawaited(
|
||||
onLaunchPlayer(
|
||||
displayItem,
|
||||
sortedSources,
|
||||
fromStart: true,
|
||||
overrideSubtitleIdx: effectiveSubtitleIdx,
|
||||
backdropUrl: backdropUrl,
|
||||
),
|
||||
),
|
||||
onPlay: () => unawaited(
|
||||
onLaunchPlayer(
|
||||
displayItem,
|
||||
sortedSources,
|
||||
overrideSubtitleIdx: effectiveSubtitleIdx,
|
||||
backdropUrl: backdropUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final sections = buildEmbyDetailSections(
|
||||
ref: ref,
|
||||
baseItem: baseItem,
|
||||
displayItem: displayItem,
|
||||
seriesId: seriesId,
|
||||
similarItemId: similarItemId,
|
||||
tmdbMediaRef: tmdbMediaRef,
|
||||
detailServerId: detailServerId,
|
||||
initialSeasonId: initialSeasonId,
|
||||
tmdbSeriesItem: tmdbSeriesItem,
|
||||
sortedSources: sortedSources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
selectedCrossServerCard: selectedCrossServerCard,
|
||||
currentServerName: ref.read(activeSessionProvider)?.serverName,
|
||||
onEpisodeTap: onEpisodeTap,
|
||||
onSelectSourceIndex: onSelectSourceIndex,
|
||||
onSelectCrossServerSource: onSelectCrossServerSource,
|
||||
);
|
||||
|
||||
if (isSeedOnly) {
|
||||
sections.removeRange(1, sections.length);
|
||||
}
|
||||
|
||||
final viewState = DetailViewState(
|
||||
image: imageModel,
|
||||
navTitle: detailNavTitle(displayItem, tmdbSeriesItem),
|
||||
hero: DetailHeroModel(
|
||||
title: displayItem.Name,
|
||||
logoUrl: logoUrl,
|
||||
posterUrl: embyPosterUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
rating: displayItem.CommunityRating,
|
||||
metaParts: buildDetailMetaParts(displayItem, source: selectedSrc),
|
||||
genres: extractGenres(baseItem),
|
||||
tagline: tmdbDetail?.tagline,
|
||||
overview: overview,
|
||||
actions: heroActions,
|
||||
),
|
||||
sections: sections,
|
||||
showLoadingBelowHero: isSeedOnly,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
DetailImmersiveScaffold(
|
||||
scrollController: scrollController,
|
||||
scrollProgress: scrollProgress,
|
||||
backdropUrl: viewState.image.backdropUrl,
|
||||
fallbackUrls: viewState.image.fallbackUrls,
|
||||
embyBaseUrl: viewState.image.embyBaseUrl,
|
||||
imageHeaders: viewState.image.imageHeaders,
|
||||
compact: compact,
|
||||
horizontalPadding: compact ? 16 : 32,
|
||||
child: DetailViewRenderer(state: viewState),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: DetailNavBar(
|
||||
scrollProgress: scrollProgress,
|
||||
title: viewState.navTitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../shared/utils/cross_server_search_key.dart';
|
||||
import '../../shared/utils/emby_ticks.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import '../../shared/utils/tmdb_key_utils.dart';
|
||||
|
||||
|
||||
class TmdbMediaRef {
|
||||
final String id;
|
||||
final String mediaType;
|
||||
|
||||
const TmdbMediaRef({required this.id, required this.mediaType});
|
||||
}
|
||||
|
||||
List<String> buildDetailMetaParts(
|
||||
EmbyRawItem item, {
|
||||
EmbyRawMediaSource? source,
|
||||
}) {
|
||||
final parts = <String>[];
|
||||
final seriesName = item.SeriesName;
|
||||
if (seriesName != null && seriesName.isNotEmpty) {
|
||||
parts.add(seriesName);
|
||||
}
|
||||
if (item.ProductionYear != null) {
|
||||
parts.add('${item.ProductionYear}');
|
||||
}
|
||||
final episode = episodeLabel(item);
|
||||
if (episode != null) {
|
||||
parts.add(episode);
|
||||
}
|
||||
final runtime = item.RunTimeTicks;
|
||||
if (runtime != null && runtime > 0) {
|
||||
final label = formatRuntimeMinutes(runtime ~/ kTicksPerMinute);
|
||||
if (label != null) parts.add(label);
|
||||
}
|
||||
if (source != null) {
|
||||
final videoStream = findVideoStream(source);
|
||||
if (videoStream != null) {
|
||||
final h = videoStream.extra['Height'] as int? ?? source.Height;
|
||||
final range = videoStream.extra['VideoRange'] as String?;
|
||||
if (h != null) {
|
||||
final label = heightToLabel(h);
|
||||
parts.add(range != null && range.isNotEmpty ? '$label $range' : label);
|
||||
}
|
||||
}
|
||||
final size = source.Size;
|
||||
if (size != null && size > 0) parts.add(formatFileSize(size));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
EmbyRawMediaStream? findVideoStream(EmbyRawMediaSource source) {
|
||||
final streams = source.MediaStreams;
|
||||
if (streams == null) return null;
|
||||
for (final s in streams) {
|
||||
if (s.Type == 'Video') return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String heightToLabel(int h) {
|
||||
if (h >= 2160) return '4K';
|
||||
if (h >= 1440) return '2K';
|
||||
if (h >= 1080) return '1080P';
|
||||
if (h >= 720) return '720P';
|
||||
return '${h}P';
|
||||
}
|
||||
|
||||
String? episodeLabel(EmbyRawItem item) {
|
||||
final seasonName = item.SeasonName;
|
||||
final index = item.IndexNumber;
|
||||
if (seasonName == null && index == null) return null;
|
||||
if (seasonName != null && seasonName.isNotEmpty && index != null) {
|
||||
return '$seasonName ${formatEpisodeNumber(index)}';
|
||||
}
|
||||
if (seasonName != null && seasonName.isNotEmpty) return seasonName;
|
||||
return formatEpisodeNumber(index);
|
||||
}
|
||||
|
||||
String detailNavTitle(EmbyRawItem item, EmbyRawItem? seriesItem) {
|
||||
if (item.Type != 'Episode') return item.Name;
|
||||
final seriesName = item.SeriesName;
|
||||
if (seriesName != null && seriesName.isNotEmpty) return seriesName;
|
||||
final loadedSeriesName = seriesItem?.Name;
|
||||
if (loadedSeriesName != null && loadedSeriesName.isNotEmpty) {
|
||||
return loadedSeriesName;
|
||||
}
|
||||
return item.Name;
|
||||
}
|
||||
|
||||
String? seriesIdForEpisode(EmbyRawItem item, String routeItemId) {
|
||||
if (item.Type != 'Episode') return null;
|
||||
final seriesId = item.SeriesId;
|
||||
if (seriesId != null && seriesId.isNotEmpty) return seriesId;
|
||||
if (routeItemId.isNotEmpty && routeItemId != item.Id) return routeItemId;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Map<String, String> lowercaseProviderIds(Map<String, String> ids) {
|
||||
return {
|
||||
for (final e in ids.entries)
|
||||
if (e.key.isNotEmpty && e.value.isNotEmpty) e.key.toLowerCase(): e.value,
|
||||
};
|
||||
}
|
||||
|
||||
List<String> extractGenres(EmbyRawItem item) {
|
||||
final genresRaw = item.extra['Genres'];
|
||||
if (genresRaw is List) {
|
||||
return genresRaw.whereType<String>().where((g) => g.isNotEmpty).toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
double? detailProgressPercent(EmbyRawItem item) {
|
||||
final percent = playbackProgress(item) * 100;
|
||||
return percent < 1 ? null : percent;
|
||||
}
|
||||
|
||||
String tmdbIdFromDetailItem(EmbyRawItem item) => tmdbIdFromItem(item);
|
||||
|
||||
TmdbMediaRef? tmdbMediaRefFor(EmbyRawItem item, EmbyRawItem? tmdbSeriesItem) {
|
||||
final String mediaType;
|
||||
final String tmdbId;
|
||||
switch (item.Type) {
|
||||
case 'Movie':
|
||||
mediaType = 'movie';
|
||||
tmdbId = tmdbIdFromDetailItem(item);
|
||||
case 'Series':
|
||||
mediaType = 'tv';
|
||||
tmdbId = tmdbIdFromDetailItem(item);
|
||||
case 'Episode':
|
||||
final seriesItem = tmdbSeriesItem;
|
||||
if (seriesItem == null) return null;
|
||||
mediaType = 'tv';
|
||||
tmdbId = tmdbIdFromDetailItem(seriesItem);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (tmdbId.isEmpty) return null;
|
||||
return TmdbMediaRef(id: tmdbId, mediaType: mediaType);
|
||||
}
|
||||
|
||||
CrossServerSearchReq? buildCrossServerReqForDetail(
|
||||
EmbyRawItem displayItem,
|
||||
EmbyRawItem? seriesItem,
|
||||
) {
|
||||
final providerIds = providerIdsOfItem(displayItem);
|
||||
if (displayItem.Type == 'Movie') {
|
||||
if (providerIds.isEmpty && displayItem.Name.isEmpty) return null;
|
||||
return CrossServerSearchReq(
|
||||
anchorType: 'Movie',
|
||||
itemName: displayItem.Name,
|
||||
providerIds: providerIds,
|
||||
);
|
||||
}
|
||||
if (displayItem.Type == 'Episode') {
|
||||
return buildEpisodeCrossServerReq(
|
||||
episode: displayItem,
|
||||
seriesItem: seriesItem,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import 'emby_detail_helpers.dart';
|
||||
import 'widgets/hero_action_buttons.dart';
|
||||
import 'widgets/stream_selector_actions.dart';
|
||||
|
||||
Widget buildEmbyHeroActions({
|
||||
required EmbyRawItem displayItem,
|
||||
required bool canPlay,
|
||||
required List<EmbyRawMediaSource> sortedSources,
|
||||
required List<EmbyRawMediaStream> subtitles,
|
||||
required List<EmbyRawMediaStream> audios,
|
||||
required int? effectiveSubtitleIdx,
|
||||
required bool compact,
|
||||
required int selectedSourceIdx,
|
||||
required int? selectedSubtitleIdx,
|
||||
required int selectedAudioIdx,
|
||||
required bool isPlayed,
|
||||
required bool isFavorite,
|
||||
required bool isLaunchingPlayer,
|
||||
required ValueChanged<int> onSelectSource,
|
||||
required ValueChanged<int?> onSelectSubtitle,
|
||||
required ValueChanged<int> onSelectAudio,
|
||||
required VoidCallback onTogglePlayed,
|
||||
required VoidCallback onToggleFavorite,
|
||||
required VoidCallback onPlay,
|
||||
required VoidCallback onReplay,
|
||||
}) {
|
||||
final btnH = compact ? 44.0 : 52.0;
|
||||
final progressPercent = detailProgressPercent(displayItem);
|
||||
final hasProgress = progressPercent != null && progressPercent > 0;
|
||||
final pos = displayItem.UserData?.PlaybackPositionTicks;
|
||||
final positionClock = pos != null && pos > 0 ? formatTicksAsClock(pos) : null;
|
||||
|
||||
final extraActions = buildStreamSelectorPills(
|
||||
subtitles: subtitles,
|
||||
audios: audios,
|
||||
selectedSubtitleIdx: selectedSubtitleIdx,
|
||||
selectedAudioIdx: selectedAudioIdx,
|
||||
onSelectSubtitle: onSelectSubtitle,
|
||||
onSelectAudio: onSelectAudio,
|
||||
height: btnH,
|
||||
);
|
||||
|
||||
if (sortedSources.length > 1) {
|
||||
extraActions.add(
|
||||
HeroPillDropdownButton(
|
||||
height: btnH,
|
||||
icon: Icons.video_file_outlined,
|
||||
menuChildren: buildVersionMenuButtons(
|
||||
sources: sortedSources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
onSelectSource: onSelectSource,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return DetailHeroActions(
|
||||
showReplay: hasProgress && canPlay,
|
||||
onReplay: canPlay ? () => unawaited(Future<void>.sync(onReplay)) : null,
|
||||
isPlayed: isPlayed,
|
||||
onTogglePlayed: () => unawaited(Future<void>.sync(onTogglePlayed)),
|
||||
isFavorite: isFavorite,
|
||||
onToggleFavorite: () => unawaited(Future<void>.sync(onToggleFavorite)),
|
||||
extraIconActions: extraActions,
|
||||
onPlay: canPlay ? () => unawaited(Future<void>.sync(onPlay)) : null,
|
||||
playLabel: hasProgress ? '继续播放' : '播放',
|
||||
progressPercent: progressPercent,
|
||||
trailingLabel: positionClock,
|
||||
isPlayLoading: isLaunchingPlayer,
|
||||
compact: compact,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import 'android/android_media_info_section.dart';
|
||||
import 'widgets/additional_parts_section.dart';
|
||||
import 'widgets/emby_cast_section.dart';
|
||||
import 'widgets/episode_list_section.dart';
|
||||
import 'widgets/similar_section.dart';
|
||||
import 'widgets/special_features_section.dart';
|
||||
import 'emby_detail_helpers.dart';
|
||||
import 'model/detail_section_model.dart';
|
||||
import 'model/episode_auto_select_args.dart';
|
||||
import 'widgets/source_selection_section.dart';
|
||||
|
||||
List<DetailSection> buildEmbyDetailSections({
|
||||
required WidgetRef ref,
|
||||
required EmbyRawItem baseItem,
|
||||
required EmbyRawItem displayItem,
|
||||
required String? seriesId,
|
||||
required String similarItemId,
|
||||
required TmdbMediaRef? tmdbMediaRef,
|
||||
required String? detailServerId,
|
||||
required String? initialSeasonId,
|
||||
required EmbyRawItem? tmdbSeriesItem,
|
||||
required List<EmbyRawMediaSource> sortedSources,
|
||||
required int selectedSourceIdx,
|
||||
required CrossServerSourceCard? selectedCrossServerCard,
|
||||
required String? currentServerName,
|
||||
required ValueChanged<EmbyRawItem> onEpisodeTap,
|
||||
required ValueChanged<int> onSelectSourceIndex,
|
||||
required ValueChanged<CrossServerSourceCard> onSelectCrossServerSource,
|
||||
EmbyRawMediaSource? activeSource,
|
||||
List<String> studioNames = const [],
|
||||
List<String> directors = const [],
|
||||
|
||||
|
||||
Widget? sectionLeading,
|
||||
}) {
|
||||
return <DetailSection>[
|
||||
if (seriesId != null)
|
||||
DetailSection(
|
||||
id: 'emby_episodes',
|
||||
child: _buildEpisodesSection(
|
||||
ref: ref,
|
||||
baseItem: baseItem,
|
||||
displayItem: displayItem,
|
||||
seriesId: seriesId,
|
||||
tmdbMediaRef: tmdbMediaRef,
|
||||
detailServerId: detailServerId,
|
||||
initialSeasonId: initialSeasonId,
|
||||
tmdbSeriesItem: tmdbSeriesItem,
|
||||
onEpisodeTap: onEpisodeTap,
|
||||
leading: sectionLeading,
|
||||
),
|
||||
),
|
||||
if (displayItem.Type == 'Movie' || displayItem.Type == 'Episode')
|
||||
DetailSection(
|
||||
id: 'emby_source',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: _buildSourceSection(
|
||||
baseItem: baseItem,
|
||||
displayItem: displayItem,
|
||||
tmdbSeriesItem: tmdbSeriesItem,
|
||||
sortedSources: sortedSources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
selectedCrossServerCard: selectedCrossServerCard,
|
||||
currentServerName: currentServerName,
|
||||
onSelectSourceIndex: onSelectSourceIndex,
|
||||
onSelectCrossServerSource: onSelectCrossServerSource,
|
||||
leading: sectionLeading,
|
||||
),
|
||||
),
|
||||
),
|
||||
DetailSection(
|
||||
id: 'emby_cast',
|
||||
child: EmbyCastSection(
|
||||
item: baseItem,
|
||||
embedded: true,
|
||||
leading: sectionLeading,
|
||||
),
|
||||
),
|
||||
DetailSection(
|
||||
id: 'emby_media_detail',
|
||||
child: AndroidMediaInfoSection(
|
||||
source: activeSource,
|
||||
studioNames: studioNames,
|
||||
directors: directors,
|
||||
leading: sectionLeading,
|
||||
),
|
||||
),
|
||||
if (baseItem.Type != 'Series')
|
||||
DetailSection(
|
||||
id: 'emby_additional_parts',
|
||||
child: AdditionalPartsSection(
|
||||
itemId: displayItem.Id,
|
||||
embedded: true,
|
||||
leading: sectionLeading,
|
||||
),
|
||||
),
|
||||
if (baseItem.Type != 'Series')
|
||||
DetailSection(
|
||||
id: 'emby_special_features',
|
||||
child: SpecialFeaturesSection(
|
||||
itemId: displayItem.Id,
|
||||
embedded: true,
|
||||
leading: sectionLeading,
|
||||
),
|
||||
),
|
||||
DetailSection(
|
||||
id: 'emby_similar',
|
||||
child: SimilarSection(
|
||||
itemId: similarItemId,
|
||||
embedded: true,
|
||||
leading: sectionLeading,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildEpisodesSection({
|
||||
required WidgetRef ref,
|
||||
required EmbyRawItem baseItem,
|
||||
required EmbyRawItem displayItem,
|
||||
required String seriesId,
|
||||
required TmdbMediaRef? tmdbMediaRef,
|
||||
required String? detailServerId,
|
||||
required String? initialSeasonId,
|
||||
required EmbyRawItem? tmdbSeriesItem,
|
||||
required ValueChanged<EmbyRawItem> onEpisodeTap,
|
||||
Widget? leading,
|
||||
}) {
|
||||
final showItem = baseItem.Type == 'Series' ? baseItem : tmdbSeriesItem;
|
||||
final resumeAsync = baseItem.Type == 'Series' && detailServerId != null
|
||||
? ref.watch(
|
||||
seriesResumeTargetProvider((
|
||||
serverId: detailServerId,
|
||||
seriesId: seriesId,
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
final autoSelect = EpisodeAutoSelectArgs.fromResume(resumeAsync);
|
||||
return EpisodeListSection(
|
||||
seriesId: seriesId,
|
||||
tmdbSeriesId: tmdbMediaRef != null && tmdbMediaRef.mediaType == 'tv'
|
||||
? tmdbMediaRef.id
|
||||
: null,
|
||||
currentEpisodeId: displayItem.Type == 'Episode' ? displayItem.Id : null,
|
||||
currentEpisodeSeasonId: displayItem.Type == 'Episode'
|
||||
? displayItem.SeasonId
|
||||
: null,
|
||||
currentEpisodeSeasonNumber: displayItem.Type == 'Episode'
|
||||
? (displayItem.extra['ParentIndexNumber'] as num?)?.toInt()
|
||||
: null,
|
||||
currentEpisodeIndexNumber: displayItem.Type == 'Episode'
|
||||
? displayItem.IndexNumber
|
||||
: null,
|
||||
initialSeasonId: initialSeasonId,
|
||||
autoSelectFirstEpisode: autoSelect.autoSelectFirstEpisode,
|
||||
autoSelectEpisodeId: autoSelect.autoSelectEpisodeId,
|
||||
autoSelectSeasonId: autoSelect.autoSelectSeasonId,
|
||||
onEpisodeTap: onEpisodeTap,
|
||||
embedded: true,
|
||||
showProviderIds: showItem != null
|
||||
? lowercaseProviderIds(providerIdsOfItem(showItem))
|
||||
: null,
|
||||
showTitle: showItem?.Name,
|
||||
showYear: showItem?.ProductionYear,
|
||||
leading: leading,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSourceSection({
|
||||
required EmbyRawItem baseItem,
|
||||
required EmbyRawItem displayItem,
|
||||
required EmbyRawItem? tmdbSeriesItem,
|
||||
required List<EmbyRawMediaSource> sortedSources,
|
||||
required int selectedSourceIdx,
|
||||
required CrossServerSourceCard? selectedCrossServerCard,
|
||||
required String? currentServerName,
|
||||
required ValueChanged<int> onSelectSourceIndex,
|
||||
required ValueChanged<CrossServerSourceCard> onSelectCrossServerSource,
|
||||
Widget? leading,
|
||||
}) {
|
||||
final seriesItemForReq = baseItem.Type == 'Series'
|
||||
? baseItem
|
||||
: tmdbSeriesItem;
|
||||
final req = buildCrossServerReqForDetail(displayItem, seriesItemForReq);
|
||||
return SourceSelectionSection(
|
||||
req: req,
|
||||
localSources: sortedSources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
selectedCrossServerCard: selectedCrossServerCard,
|
||||
currentServerName: currentServerName,
|
||||
localMediaInfoItem: displayItem,
|
||||
showLoadingWhenEmpty: true,
|
||||
loadingText: '正在查找可用版本…',
|
||||
onLocalSourceTap: (src, index) => onSelectSourceIndex(index),
|
||||
onCrossServerSourceTap: onSelectCrossServerSource,
|
||||
leading: leading,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/emby_headers_provider.dart';
|
||||
import '../../providers/last_played_version_store.dart';
|
||||
import '../../providers/playback_info_prefetch_provider.dart';
|
||||
import '../../providers/playback_active_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/version_priority_provider.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/utils/cross_server_search_key.dart';
|
||||
import '../../shared/utils/app_logger.dart';
|
||||
import '../../shared/widgets/app_snack_bar.dart';
|
||||
import '../player/player_launcher.dart';
|
||||
import 'emby_detail_body_android.dart';
|
||||
import 'emby_detail_body_builder.dart';
|
||||
import 'emby_detail_helpers.dart';
|
||||
import 'android/android_detail_geometry.dart';
|
||||
import 'model/selected_episode_merge.dart';
|
||||
import 'model/stream_selection.dart';
|
||||
import 'widgets/detail_scroll_progress.dart';
|
||||
|
||||
|
||||
class EmbyDetailView extends ConsumerStatefulWidget {
|
||||
final String itemId;
|
||||
final String? initialSeriesId;
|
||||
|
||||
|
||||
final String? initialSeasonId;
|
||||
|
||||
|
||||
final EmbyRawItem? seedItem;
|
||||
|
||||
const EmbyDetailView({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
this.initialSeriesId,
|
||||
this.initialSeasonId,
|
||||
this.seedItem,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<EmbyDetailView> createState() => _EmbyDetailViewState();
|
||||
}
|
||||
|
||||
class _EmbyDetailViewState extends ConsumerState<EmbyDetailView> {
|
||||
static const _tag = 'EmbyDetailView';
|
||||
|
||||
Future<MediaDetailRes>? _detailFuture;
|
||||
MediaDetailRes? _data;
|
||||
EmbyRawItem? _selectedItem;
|
||||
EmbyRawItem? _tmdbSeriesItem;
|
||||
bool _isFavorite = false;
|
||||
bool _isPlayed = false;
|
||||
int _selectedSourceIdx = 0;
|
||||
int? _selectedSubtitleIdx;
|
||||
int _selectedAudioIdx = 0;
|
||||
bool _isLaunchingPlayer = false;
|
||||
|
||||
String? _detailServerId;
|
||||
String? _originServerId;
|
||||
bool _serverReloadScheduled = false;
|
||||
String? _overrideItemId;
|
||||
String? _pendingMediaSourceId;
|
||||
bool _isRestoringOriginServer = false;
|
||||
|
||||
|
||||
CrossServerSourceCard? _selectedCrossServerCard;
|
||||
|
||||
|
||||
bool _crossServerPlaybackInFlight = false;
|
||||
bool _backInFlight = false;
|
||||
final _scrollController = ScrollController();
|
||||
final _scrollProgress = ValueNotifier<double>(0.0);
|
||||
late final DetailPageActivity _detailActivity;
|
||||
|
||||
|
||||
bool _didEnterDetailActivity = false;
|
||||
|
||||
String get _detailItemId {
|
||||
if (_overrideItemId != null && _overrideItemId!.isNotEmpty) {
|
||||
return _overrideItemId!;
|
||||
}
|
||||
return widget.itemId;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_detailActivity = ref.read(detailPageActivityProvider.notifier);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_didEnterDetailActivity = true;
|
||||
_detailActivity.enter();
|
||||
});
|
||||
_originServerId = ref.read(activeSessionProvider)?.serverId;
|
||||
_scrollController.addListener(_onScroll);
|
||||
_reload();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_didEnterDetailActivity) _detailActivity.leave();
|
||||
});
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
_scrollProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
_scrollProgress.value = computeDetailScrollProgress(
|
||||
context,
|
||||
_scrollController.offset,
|
||||
collapseExtent: Platform.isAndroid
|
||||
? computeAndroidDetailCollapseExtent(context)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EmbyDetailView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.itemId != widget.itemId ||
|
||||
oldWidget.initialSeriesId != widget.initialSeriesId ||
|
||||
oldWidget.initialSeasonId != widget.initialSeasonId) {
|
||||
_overrideItemId = null;
|
||||
_reload();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _reload({bool invalidateCache = false}) {
|
||||
_detailServerId = ref.read(activeSessionProvider)?.serverId;
|
||||
_serverReloadScheduled = false;
|
||||
|
||||
_resetScrollState();
|
||||
if (invalidateCache) {
|
||||
final serverId = _detailServerId ?? '';
|
||||
ref.invalidate(
|
||||
mediaDetailDataProvider((serverId: serverId, itemId: _detailItemId)),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_data = null;
|
||||
_selectedItem = null;
|
||||
_tmdbSeriesItem = null;
|
||||
_selectedSourceIdx = 0;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
_selectedCrossServerCard = null;
|
||||
_isLaunchingPlayer = false;
|
||||
_detailFuture = _loadDetail();
|
||||
});
|
||||
}
|
||||
|
||||
void _resetScrollState() {
|
||||
_scrollProgress.value = 0.0;
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<MediaDetailRes> _loadDetail() async {
|
||||
final capturedServerId = _detailServerId ?? '';
|
||||
final initialSeriesId = widget.initialSeriesId;
|
||||
if (initialSeriesId != null &&
|
||||
initialSeriesId.isNotEmpty &&
|
||||
capturedServerId.isNotEmpty) {
|
||||
unawaited(
|
||||
ref.read(
|
||||
mediaDetailDataProvider((
|
||||
serverId: capturedServerId,
|
||||
itemId: initialSeriesId,
|
||||
)).future,
|
||||
),
|
||||
);
|
||||
}
|
||||
final res = await ref.read(
|
||||
mediaDetailDataProvider((
|
||||
serverId: capturedServerId,
|
||||
itemId: _detailItemId,
|
||||
)).future,
|
||||
);
|
||||
if (!mounted) return res;
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'_loadDetail entry itemId=$_detailItemId '
|
||||
'detailServerId=$capturedServerId '
|
||||
'activeServerId=${ref.read(activeSessionProvider)?.serverId} '
|
||||
'base.Id=${res.base.Id} type=${res.base.Type} '
|
||||
'seriesId=${res.base.SeriesId} seasonId=${res.base.SeasonId} '
|
||||
'index=${res.base.IndexNumber} '
|
||||
'sources=${mediaSourcesOfItem(res.base).length}',
|
||||
);
|
||||
setState(() {
|
||||
_data = res;
|
||||
_selectedItem = res.base;
|
||||
_isFavorite = res.base.UserData?.IsFavorite ?? false;
|
||||
_isPlayed = res.base.UserData?.Played ?? false;
|
||||
});
|
||||
unawaited(_loadRememberedVersion(res.base));
|
||||
final seriesId = seriesIdForEpisode(res.base, widget.itemId);
|
||||
if (seriesId != null) {
|
||||
unawaited(_loadSeriesInfo(seriesId, capturedServerId, res.base));
|
||||
} else {
|
||||
_firePrewarm(res.base);
|
||||
}
|
||||
_prefetchPlaybackInfoFor(res.base);
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<void> _loadSeriesInfo(
|
||||
String seriesId,
|
||||
String serverId,
|
||||
EmbyRawItem episode,
|
||||
) async {
|
||||
try {
|
||||
final seriesRes = await ref.read(
|
||||
mediaDetailDataProvider((serverId: serverId, itemId: seriesId)).future,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tmdbSeriesItem = seriesRes.base;
|
||||
});
|
||||
_firePrewarm(seriesRes.base);
|
||||
} catch (_) {
|
||||
_firePrewarm(episode);
|
||||
}
|
||||
}
|
||||
|
||||
void _firePrewarm(EmbyRawItem refItem) {
|
||||
if (refItem.Type != 'Series' && refItem.Type != 'Episode') return;
|
||||
final pids = providerIdsOfItem(refItem);
|
||||
if (pids.isEmpty) return;
|
||||
final activeServerId = ref.read(activeSessionProvider)?.serverId ?? '';
|
||||
if (activeServerId.isEmpty) return;
|
||||
unawaited(
|
||||
ref.read(
|
||||
seriesPrewarmProvider((
|
||||
activeServerId: activeServerId,
|
||||
seriesProviderIdQuery: encodeProviderIdsForKey(pids),
|
||||
)).future,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncDetailWithActiveServer(String serverId) {
|
||||
final loadedServerId = _detailServerId;
|
||||
if (loadedServerId == null) {
|
||||
_detailServerId = serverId;
|
||||
return;
|
||||
}
|
||||
if (_crossServerPlaybackInFlight) return;
|
||||
if (loadedServerId == serverId || _serverReloadScheduled) return;
|
||||
|
||||
_serverReloadScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_serverReloadScheduled = false;
|
||||
|
||||
final currentServerId = ref.read(activeSessionProvider)?.serverId;
|
||||
if (currentServerId == null || _detailServerId == currentServerId) {
|
||||
return;
|
||||
}
|
||||
_reload(invalidateCache: true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _selectCrossServerSource(CrossServerSourceCard card) {
|
||||
setState(() {
|
||||
_selectedCrossServerCard = card;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
});
|
||||
_prefetchPlaybackInfoForCrossServer(card);
|
||||
}
|
||||
|
||||
void _onEpisodeTap(EmbyRawItem episode) {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'_onEpisodeTap id=${episode.Id} name=${episode.Name} '
|
||||
'type=${episode.Type} seriesId=${episode.SeriesId} '
|
||||
'seasonId=${episode.SeasonId} index=${episode.IndexNumber} '
|
||||
'positionTicks=${episode.UserData?.PlaybackPositionTicks} '
|
||||
'sources=${mediaSourcesOfItem(episode).length}',
|
||||
);
|
||||
setState(() {
|
||||
_selectedItem = episode;
|
||||
_isFavorite = episode.UserData?.IsFavorite ?? false;
|
||||
_isPlayed = episode.UserData?.Played ?? false;
|
||||
_selectedSourceIdx = 0;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
_selectedCrossServerCard = null;
|
||||
});
|
||||
unawaited(_loadSelectedEpisodeDetail(episode.Id));
|
||||
}
|
||||
|
||||
|
||||
Future<void> _loadSelectedEpisodeDetail(String episodeId) async {
|
||||
final serverId = _detailServerId ?? '';
|
||||
if (serverId.isEmpty || episodeId.isEmpty) return;
|
||||
try {
|
||||
final res = await ref.read(
|
||||
mediaDetailDataProvider((serverId: serverId, itemId: episodeId)).future,
|
||||
);
|
||||
final listItem = _selectedItem;
|
||||
if (!mounted || listItem?.Id != episodeId) return;
|
||||
final merged = listItem != null
|
||||
? mergeSelectedEpisodeDetail(detail: res.base, listItem: listItem)
|
||||
: res.base;
|
||||
final mergedSources = mediaSourcesOfItem(merged);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'_loadSelectedEpisodeDetail episodeId=$episodeId '
|
||||
'listItem.Id=${listItem?.Id} detail.base.Id=${res.base.Id} '
|
||||
'merged.Id=${merged.Id} mergedSources=${mergedSources.length} '
|
||||
'firstSourceId=${mergedSources.isNotEmpty ? mergedSources.first.Id : null}',
|
||||
);
|
||||
setState(() {
|
||||
_selectedItem = merged;
|
||||
_isFavorite = merged.UserData?.IsFavorite ?? false;
|
||||
_isPlayed = merged.UserData?.Played ?? false;
|
||||
});
|
||||
unawaited(_loadRememberedVersion(merged));
|
||||
_prefetchPlaybackInfoFor(merged);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _selectSourceIndex(int index) {
|
||||
setState(() {
|
||||
_selectedSourceIdx = index;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
_selectedCrossServerCard = null;
|
||||
_pendingMediaSourceId = null;
|
||||
});
|
||||
final item = _selectedItem;
|
||||
if (item != null) _prefetchPlaybackInfoFor(item);
|
||||
}
|
||||
|
||||
void _selectSubtitleIndex(int? index) {
|
||||
setState(() => _selectedSubtitleIdx = index);
|
||||
}
|
||||
|
||||
void _selectAudioIndex(int index) {
|
||||
setState(() => _selectedAudioIdx = index);
|
||||
}
|
||||
|
||||
void _applyPendingMediaSource(int index) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedSourceIdx = index;
|
||||
_pendingMediaSourceId = null;
|
||||
});
|
||||
final item = _selectedItem;
|
||||
if (item != null) _prefetchPlaybackInfoFor(item);
|
||||
}
|
||||
|
||||
void _clearPendingMediaSource() {
|
||||
_pendingMediaSourceId = null;
|
||||
}
|
||||
|
||||
int _prefetchStartTimeTicksForCrossServer(CrossServerSourceCard card) {
|
||||
final selectedTicks = _selectedItem?.UserData?.PlaybackPositionTicks;
|
||||
if (selectedTicks != null && selectedTicks > 0) return selectedTicks;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _prefetchPlaybackInfoFor(EmbyRawItem item) {
|
||||
final serverId = _detailServerId ?? '';
|
||||
if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return;
|
||||
final key = _playbackInfoPrefetchKeyFor(
|
||||
item,
|
||||
mediaSourceId: _currentLocalMediaSourceIdFor(item),
|
||||
fromStart: false,
|
||||
);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'playbackInfo prefetch request local '
|
||||
'${describePlaybackInfoPrefetchKey(key)}',
|
||||
);
|
||||
unawaited(ref.read(playbackInfoPrefetchProvider(key).future));
|
||||
}
|
||||
|
||||
void _prefetchPlaybackInfoForCrossServer(CrossServerSourceCard card) {
|
||||
if (card.serverId.isEmpty ||
|
||||
card.landingItemId.isEmpty ||
|
||||
card.item.Type == 'Series') {
|
||||
return;
|
||||
}
|
||||
final key = (
|
||||
serverId: card.serverId,
|
||||
itemId: card.landingItemId,
|
||||
mediaSourceId: card.mediaSourceId.isEmpty ? null : card.mediaSourceId,
|
||||
startTimeTicks: _prefetchStartTimeTicksForCrossServer(card),
|
||||
);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'playbackInfo prefetch request crossServer '
|
||||
'${describePlaybackInfoPrefetchKey(key)}',
|
||||
);
|
||||
unawaited(ref.read(playbackInfoPrefetchProvider(key).future));
|
||||
}
|
||||
|
||||
PlaybackInfoPrefetchKey _playbackInfoPrefetchKeyFor(
|
||||
EmbyRawItem item, {
|
||||
String? mediaSourceId,
|
||||
required bool fromStart,
|
||||
}) {
|
||||
return (
|
||||
serverId: _detailServerId ?? '',
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
startTimeTicks: fromStart
|
||||
? 0
|
||||
: (item.UserData?.PlaybackPositionTicks ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
String? _currentLocalMediaSourceIdFor(EmbyRawItem item) {
|
||||
if (_selectedCrossServerCard != null) return null;
|
||||
final priorities =
|
||||
ref.read(versionPriorityProvider).value?.activePriorities ?? [];
|
||||
final sources = sortMediaSources(mediaSourcesOfItem(item), priorities);
|
||||
if (_selectedSourceIdx < 0 || _selectedSourceIdx >= sources.length) {
|
||||
return null;
|
||||
}
|
||||
return sources[_selectedSourceIdx].Id;
|
||||
}
|
||||
|
||||
|
||||
Future<void> _loadRememberedVersion(EmbyRawItem item) async {
|
||||
final serverId = _detailServerId ?? '';
|
||||
if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return;
|
||||
try {
|
||||
final remembered = await ref
|
||||
.read(lastPlayedVersionStoreProvider)
|
||||
.get(serverId: serverId, itemId: item.Id);
|
||||
if (!mounted || remembered == null) return;
|
||||
if (_selectedItem?.Id != item.Id) return;
|
||||
if (_selectedSourceIdx != 0) return;
|
||||
setState(() => _pendingMediaSourceId = remembered);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _refreshSelectedItemState(EmbyRawItem item) {
|
||||
setState(() {
|
||||
_selectedItem = item;
|
||||
_isFavorite = item.UserData?.IsFavorite ?? false;
|
||||
_isPlayed = item.UserData?.Played ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
final item = _selectedItem;
|
||||
if (item == null) return;
|
||||
final next = !_isFavorite;
|
||||
final uc = await ref.read(setFavoriteUseCaseProvider.future);
|
||||
final res = await uc.execute(
|
||||
FavoriteSetReq(itemId: item.Id, isFavorite: next),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _isFavorite = res.isFavorite);
|
||||
final serverId = _detailServerId;
|
||||
if (serverId != null && serverId.isNotEmpty) {
|
||||
ref.invalidate(
|
||||
mediaDetailDataProvider((serverId: serverId, itemId: item.Id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePlayed() async {
|
||||
final item = _selectedItem;
|
||||
if (item == null) return;
|
||||
final next = !_isPlayed;
|
||||
final uc = await ref.read(setPlayedUseCaseProvider.future);
|
||||
final res = await uc.execute(PlayedSetReq(itemId: item.Id, played: next));
|
||||
if (!mounted) return;
|
||||
setState(() => _isPlayed = res.played);
|
||||
unawaited(_syncTraktPlayedChange(item, res.played));
|
||||
final serverId = _detailServerId;
|
||||
if (serverId != null && serverId.isNotEmpty) {
|
||||
ref.invalidate(
|
||||
mediaDetailDataProvider((serverId: serverId, itemId: item.Id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTraktPlayedChange(EmbyRawItem item, bool played) async {
|
||||
try {
|
||||
final service = await ref.read(traktSyncServiceProvider.future);
|
||||
await service.syncEmbyPlayedChange(
|
||||
item: item,
|
||||
played: played,
|
||||
seriesItem: _tmdbSeriesItem,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _goBack() async {
|
||||
if (_backInFlight || _isRestoringOriginServer) return;
|
||||
_backInFlight = true;
|
||||
final currentServerId = ref.read(activeSessionProvider)?.serverId;
|
||||
if (_originServerId != null && currentServerId != _originServerId) {
|
||||
setState(() => _isRestoringOriginServer = true);
|
||||
try {
|
||||
await ref
|
||||
.read(sessionProvider.notifier)
|
||||
.switchSession(_originServerId!);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isRestoringOriginServer = false);
|
||||
_backInFlight = false;
|
||||
showAppSnackBar(context, '恢复原服务器失败', tone: AppSnackTone.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (_isRestoringOriginServer) {
|
||||
setState(() => _isRestoringOriginServer = false);
|
||||
}
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchPlayer(
|
||||
EmbyRawItem displayItem,
|
||||
List<EmbyRawMediaSource> sources, {
|
||||
bool fromStart = false,
|
||||
int? overrideSubtitleIdx,
|
||||
String? backdropUrl,
|
||||
}) async {
|
||||
if (_isLaunchingPlayer) return;
|
||||
setState(() => _isLaunchingPlayer = true);
|
||||
try {
|
||||
final crossServerCard = _selectedCrossServerCard;
|
||||
final selectedSrc = _selectedSourceIdx < sources.length
|
||||
? sources[_selectedSourceIdx]
|
||||
: null;
|
||||
final subIdx = overrideSubtitleIdx ?? _selectedSubtitleIdx;
|
||||
final audioIdx = _selectedAudioIdx;
|
||||
final streamSelection = resolveStreamSelection(
|
||||
selectedSrc,
|
||||
subIdx,
|
||||
audioIdx,
|
||||
);
|
||||
final subStreamIndex = streamSelection.preferredSubtitleStreamIndex;
|
||||
final audioStreamIndex = streamSelection.preferredAudioStreamIndex;
|
||||
|
||||
final crossServerMediaSourceId =
|
||||
crossServerCard?.mediaSourceId.isEmpty ?? true
|
||||
? null
|
||||
: crossServerCard?.mediaSourceId;
|
||||
final playedItemId = crossServerCard?.landingItemId ?? displayItem.Id;
|
||||
final playServerId = crossServerCard?.serverId ?? _detailServerId;
|
||||
final playMediaSourceId = crossServerCard != null
|
||||
? crossServerMediaSourceId
|
||||
: selectedSrc?.Id;
|
||||
final playStartTicks = crossServerCard != null
|
||||
? _prefetchStartTimeTicksForCrossServer(crossServerCard)
|
||||
: (displayItem.UserData?.PlaybackPositionTicks ?? 0);
|
||||
final effectiveFromStart = crossServerCard != null
|
||||
? (fromStart || playStartTicks <= 0)
|
||||
: fromStart;
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'_launchPlayer displayItem.Id=$playedItemId name=${displayItem.Name} '
|
||||
'type=${displayItem.Type} selectedSourceIdx=$_selectedSourceIdx '
|
||||
'serverId=$playServerId crossServer=${crossServerCard != null} '
|
||||
'selectedSrc.Id=${selectedSrc?.Id} sources=${sources.length} '
|
||||
'subIdx=$subIdx audioIdx=$audioIdx '
|
||||
'subStreamIndex=$subStreamIndex audioStreamIndex=$audioStreamIndex '
|
||||
'fromStart=$effectiveFromStart '
|
||||
'positionTicks=$playStartTicks',
|
||||
);
|
||||
Future<PlayerExitResult?> doLaunch() => PlayerLauncher.launch(
|
||||
context: context,
|
||||
itemId: playedItemId,
|
||||
serverId: playServerId?.isEmpty ?? true ? null : playServerId,
|
||||
mediaSourceId: playMediaSourceId,
|
||||
preferredSubtitleStreamIndex: crossServerCard != null
|
||||
? -1
|
||||
: subStreamIndex,
|
||||
preferredAudioStreamIndex: crossServerCard != null
|
||||
? null
|
||||
: audioStreamIndex,
|
||||
startPositionTicks: effectiveFromStart ? null : playStartTicks,
|
||||
fromStart: effectiveFromStart,
|
||||
backdropUrl: backdropUrl,
|
||||
);
|
||||
final PlayerExitResult? exitResult;
|
||||
if (crossServerCard != null) {
|
||||
exitResult = await _withCrossServerSession(
|
||||
crossServerCard,
|
||||
action: doLaunch,
|
||||
);
|
||||
} else {
|
||||
exitResult = await doLaunch();
|
||||
}
|
||||
final positionTicks = exitResult?.positionTicks;
|
||||
if (mounted && positionTicks != null && positionTicks > 0) {
|
||||
final item = _selectedItem;
|
||||
if (item != null && item.Id == exitResult?.itemId) {
|
||||
final userData = (item.UserData ?? const EmbyRawUserData()).copyWith(
|
||||
PlaybackPositionTicks: positionTicks,
|
||||
);
|
||||
_refreshSelectedItemState(item.copyWith(UserData: userData));
|
||||
final exitSourceId = exitResult?.mediaSourceId;
|
||||
if (exitSourceId != null && exitSourceId.isNotEmpty) {
|
||||
final idx = sources.indexWhere((s) => s.Id == exitSourceId);
|
||||
if (idx != -1 && idx != _selectedSourceIdx) {
|
||||
_selectSourceIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unawaited(
|
||||
_refreshSelectedItemProgress(
|
||||
displayItem.Id,
|
||||
optimisticTicks: positionTicks,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted && _isLaunchingPlayer) {
|
||||
setState(() => _isLaunchingPlayer = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _withCrossServerSession<T>(
|
||||
CrossServerSourceCard card, {
|
||||
required Future<T> Function() action,
|
||||
}) async {
|
||||
_crossServerPlaybackInFlight = true;
|
||||
final detailServerId = _detailServerId;
|
||||
try {
|
||||
if (ref.read(activeSessionProvider)?.serverId != card.serverId) {
|
||||
await ref.read(sessionProvider.notifier).switchSession(card.serverId);
|
||||
}
|
||||
return await action();
|
||||
} finally {
|
||||
if (mounted &&
|
||||
detailServerId != null &&
|
||||
detailServerId.isNotEmpty &&
|
||||
ref.read(activeSessionProvider)?.serverId != detailServerId) {
|
||||
try {
|
||||
await ref
|
||||
.read(sessionProvider.notifier)
|
||||
.switchSession(detailServerId);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) _crossServerPlaybackInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshSelectedItemProgress(
|
||||
String itemId, {
|
||||
int? optimisticTicks,
|
||||
}) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final capturedServerId = _detailServerId ?? '';
|
||||
final uc = await ref.read(mediaDetailUseCaseProvider.future);
|
||||
if (!mounted) return;
|
||||
final res = await uc.execute(MediaDetailReq(itemId: itemId));
|
||||
final prev = _selectedItem;
|
||||
if (!mounted || prev?.Id != itemId) return;
|
||||
var merged = prev != null
|
||||
? mergeSelectedEpisodeDetail(detail: res.base, listItem: prev)
|
||||
: res.base;
|
||||
if (optimisticTicks != null &&
|
||||
optimisticTicks > 0 &&
|
||||
merged.UserData?.Played != true) {
|
||||
final serverTicks = merged.UserData?.PlaybackPositionTicks ?? 0;
|
||||
if (optimisticTicks > serverTicks) {
|
||||
merged = merged.copyWith(
|
||||
UserData: (merged.UserData ?? const EmbyRawUserData()).copyWith(
|
||||
PlaybackPositionTicks: optimisticTicks,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_refreshSelectedItemState(merged);
|
||||
if (capturedServerId.isNotEmpty) {
|
||||
ref.invalidate(
|
||||
mediaDetailDataProvider((serverId: capturedServerId, itemId: itemId)),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
_syncDetailWithActiveServer(session.serverId);
|
||||
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
final priorities =
|
||||
ref.watch(versionPriorityProvider).value?.activePriorities ?? [];
|
||||
|
||||
Widget buildBody(AsyncSnapshot<MediaDetailRes> snap) {
|
||||
if (Platform.isAndroid) {
|
||||
return EmbyDetailBodyAndroid(
|
||||
snap: snap,
|
||||
detailData: _data,
|
||||
seedItem: widget.seedItem,
|
||||
selectedItem: _selectedItem,
|
||||
tmdbSeriesItem: _tmdbSeriesItem,
|
||||
detailServerId: _detailServerId,
|
||||
initialSeasonId: widget.initialSeasonId,
|
||||
pendingMediaSourceId: _pendingMediaSourceId,
|
||||
|
||||
scrollController: _scrollController,
|
||||
scrollProgress: _scrollProgress,
|
||||
imageHeaders: imageHeaders,
|
||||
session: session,
|
||||
priorities: priorities,
|
||||
selectedSourceIdx: _selectedSourceIdx,
|
||||
selectedSubtitleIdx: _selectedSubtitleIdx,
|
||||
selectedAudioIdx: _selectedAudioIdx,
|
||||
selectedCrossServerCard: _selectedCrossServerCard,
|
||||
isPlayed: _isPlayed,
|
||||
isFavorite: _isFavorite,
|
||||
isLaunchingPlayer: _isLaunchingPlayer,
|
||||
onBack: () => unawaited(_goBack()),
|
||||
onRetry: () => _reload(invalidateCache: true),
|
||||
onTogglePlayed: () => unawaited(_togglePlayed()),
|
||||
onToggleFavorite: () => unawaited(_toggleFavorite()),
|
||||
onSelectSourceIndex: _selectSourceIndex,
|
||||
onSelectSubtitleIndex: _selectSubtitleIndex,
|
||||
onSelectAudioIndex: _selectAudioIndex,
|
||||
onEpisodeTap: _onEpisodeTap,
|
||||
onSelectCrossServerSource: _selectCrossServerSource,
|
||||
onApplyPendingMediaSource: _applyPendingMediaSource,
|
||||
onClearPendingMediaSource: _clearPendingMediaSource,
|
||||
onLaunchPlayer: _launchPlayer,
|
||||
);
|
||||
}
|
||||
return buildEmbyDetailBody(
|
||||
context: context,
|
||||
ref: ref,
|
||||
snap: snap,
|
||||
detailData: _data,
|
||||
seedItem: widget.seedItem,
|
||||
selectedItem: _selectedItem,
|
||||
tmdbSeriesItem: _tmdbSeriesItem,
|
||||
detailServerId: _detailServerId,
|
||||
initialSeasonId: widget.initialSeasonId,
|
||||
pendingMediaSourceId: _pendingMediaSourceId,
|
||||
|
||||
scrollController: _scrollController,
|
||||
scrollProgress: _scrollProgress,
|
||||
imageHeaders: imageHeaders,
|
||||
session: session,
|
||||
priorities: priorities,
|
||||
selectedSourceIdx: _selectedSourceIdx,
|
||||
selectedSubtitleIdx: _selectedSubtitleIdx,
|
||||
selectedAudioIdx: _selectedAudioIdx,
|
||||
selectedCrossServerCard: _selectedCrossServerCard,
|
||||
isPlayed: _isPlayed,
|
||||
isFavorite: _isFavorite,
|
||||
isLaunchingPlayer: _isLaunchingPlayer,
|
||||
onBack: () => unawaited(_goBack()),
|
||||
onRetry: () => _reload(invalidateCache: true),
|
||||
onTogglePlayed: () => unawaited(_togglePlayed()),
|
||||
onToggleFavorite: () => unawaited(_toggleFavorite()),
|
||||
onSelectSourceIndex: _selectSourceIndex,
|
||||
onSelectSubtitleIndex: _selectSubtitleIndex,
|
||||
onSelectAudioIndex: _selectAudioIndex,
|
||||
onEpisodeTap: _onEpisodeTap,
|
||||
onSelectCrossServerSource: _selectCrossServerSource,
|
||||
onApplyPendingMediaSource: _applyPendingMediaSource,
|
||||
onClearPendingMediaSource: _clearPendingMediaSource,
|
||||
onLaunchPlayer: _launchPlayer,
|
||||
);
|
||||
}
|
||||
|
||||
final content = FutureBuilder<MediaDetailRes>(
|
||||
future: _detailFuture,
|
||||
builder: (_, snap) => buildBody(snap),
|
||||
);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
return Theme(data: AppTheme.dark(), child: content);
|
||||
}
|
||||
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(backgroundColor: Colors.black, body: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
||||
class DetailHeroModel {
|
||||
final String title;
|
||||
final String? logoUrl;
|
||||
final String? posterUrl;
|
||||
final double? rating;
|
||||
final List<String> metaParts;
|
||||
final List<String> genres;
|
||||
final String? tagline;
|
||||
final String? overview;
|
||||
final Widget? actions;
|
||||
|
||||
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
const DetailHeroModel({
|
||||
required this.title,
|
||||
this.logoUrl,
|
||||
this.posterUrl,
|
||||
this.rating,
|
||||
this.metaParts = const <String>[],
|
||||
this.genres = const <String>[],
|
||||
this.tagline,
|
||||
this.overview,
|
||||
this.actions,
|
||||
this.imageHeaders,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
|
||||
class DetailImageModel {
|
||||
final String? backdropUrl;
|
||||
final List<String> fallbackUrls;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
const DetailImageModel({
|
||||
this.backdropUrl,
|
||||
this.fallbackUrls = const <String>[],
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
||||
class DetailSection {
|
||||
final String id;
|
||||
final Widget child;
|
||||
|
||||
const DetailSection({required this.id, required this.child});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'detail_hero_model.dart';
|
||||
import 'detail_image_model.dart';
|
||||
import 'detail_section_model.dart';
|
||||
|
||||
|
||||
class DetailViewState {
|
||||
|
||||
final DetailImageModel image;
|
||||
|
||||
|
||||
final String navTitle;
|
||||
|
||||
|
||||
final DetailHeroModel hero;
|
||||
|
||||
|
||||
final List<DetailSection> sections;
|
||||
|
||||
|
||||
final bool showLoadingBelowHero;
|
||||
|
||||
const DetailViewState({
|
||||
required this.image,
|
||||
required this.navTitle,
|
||||
required this.hero,
|
||||
this.sections = const <DetailSection>[],
|
||||
this.showLoadingBelowHero = false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/domain/usecases/series_resume_target.dart';
|
||||
|
||||
|
||||
class EpisodeAutoSelectArgs {
|
||||
|
||||
final bool autoSelectFirstEpisode;
|
||||
|
||||
|
||||
final String? autoSelectEpisodeId;
|
||||
|
||||
|
||||
final String? autoSelectSeasonId;
|
||||
|
||||
const EpisodeAutoSelectArgs({
|
||||
required this.autoSelectFirstEpisode,
|
||||
required this.autoSelectEpisodeId,
|
||||
required this.autoSelectSeasonId,
|
||||
});
|
||||
|
||||
|
||||
factory EpisodeAutoSelectArgs.fromResume(
|
||||
AsyncValue<SeriesResumeTarget?>? resumeAsync,
|
||||
) {
|
||||
final loading = resumeAsync?.isLoading ?? false;
|
||||
final target = resumeAsync?.value;
|
||||
return EpisodeAutoSelectArgs(
|
||||
autoSelectFirstEpisode: !loading,
|
||||
autoSelectEpisodeId: target?.episodeId,
|
||||
autoSelectSeasonId: target?.seasonId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import '../../../core/contracts/library.dart';
|
||||
|
||||
|
||||
EmbyRawItem mergeSelectedEpisodeDetail({
|
||||
required EmbyRawItem detail,
|
||||
required EmbyRawItem listItem,
|
||||
}) {
|
||||
final mergedRunTime = detail.RunTimeTicks ?? listItem.RunTimeTicks;
|
||||
final mergedUserData = _mergeUserData(detail.UserData, listItem.UserData);
|
||||
if (mergedRunTime == detail.RunTimeTicks &&
|
||||
identical(mergedUserData, detail.UserData)) {
|
||||
return detail;
|
||||
}
|
||||
return detail.copyWith(RunTimeTicks: mergedRunTime, UserData: mergedUserData);
|
||||
}
|
||||
|
||||
EmbyRawUserData? _mergeUserData(
|
||||
EmbyRawUserData? detail,
|
||||
EmbyRawUserData? listItem,
|
||||
) {
|
||||
if (listItem == null) return detail;
|
||||
if (detail == null) return listItem;
|
||||
if (detail.PlaybackPositionTicks == null &&
|
||||
listItem.PlaybackPositionTicks != null) {
|
||||
return detail.copyWith(
|
||||
PlaybackPositionTicks: listItem.PlaybackPositionTicks,
|
||||
);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
EmbyRawItem applyPlaybackReturnProgress(EmbyRawItem item, int? positionTicks) {
|
||||
if (positionTicks == null || positionTicks <= 0) return item;
|
||||
final userData = (item.UserData ?? const EmbyRawUserData()).copyWith(
|
||||
PlaybackPositionTicks: positionTicks,
|
||||
);
|
||||
return item.copyWith(UserData: userData);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../core/infra/emby_api/playback_resolver.dart';
|
||||
|
||||
|
||||
class StreamSelection {
|
||||
|
||||
final List<EmbyRawMediaStream> subtitles;
|
||||
|
||||
|
||||
final List<EmbyRawMediaStream> audios;
|
||||
|
||||
|
||||
final int preferredSubtitleStreamIndex;
|
||||
|
||||
|
||||
final int? preferredAudioStreamIndex;
|
||||
|
||||
const StreamSelection({
|
||||
required this.subtitles,
|
||||
required this.audios,
|
||||
required this.preferredSubtitleStreamIndex,
|
||||
required this.preferredAudioStreamIndex,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
EmbyRawMediaSource? resolveActiveMediaSource(
|
||||
CrossServerSourceCard? selectedCrossServerCard,
|
||||
List<EmbyRawMediaSource> localSources,
|
||||
int selectedSourceIdx,
|
||||
) {
|
||||
return selectedCrossServerCard?.mediaSource ??
|
||||
(selectedSourceIdx < localSources.length
|
||||
? localSources[selectedSourceIdx]
|
||||
: null);
|
||||
}
|
||||
|
||||
|
||||
StreamSelection resolveStreamSelection(
|
||||
EmbyRawMediaSource? source,
|
||||
int? selectedSubtitleIdx,
|
||||
int selectedAudioIdx,
|
||||
) {
|
||||
final subtitles = (source?.MediaStreams ?? [])
|
||||
.where((s) => s.Type == 'Subtitle')
|
||||
.where((s) {
|
||||
final isExternal =
|
||||
(s.IsExternal ?? false) ||
|
||||
s.DeliveryMethod == 'External' ||
|
||||
(s.DeliveryUrl != null && s.DeliveryUrl!.isNotEmpty);
|
||||
if (!isExternal) return true;
|
||||
return PlaybackResolver.isTextSubtitleCodec(s.Codec);
|
||||
})
|
||||
.toList();
|
||||
final audios = (source?.MediaStreams ?? [])
|
||||
.where((s) => s.Type == 'Audio')
|
||||
.toList();
|
||||
final preferredSubtitleStreamIndex =
|
||||
selectedSubtitleIdx != null && selectedSubtitleIdx < subtitles.length
|
||||
? subtitles[selectedSubtitleIdx].Index ?? -1
|
||||
: -1;
|
||||
final preferredAudioStreamIndex = selectedAudioIdx < audios.length
|
||||
? audios[selectedAudioIdx].Index
|
||||
: null;
|
||||
return StreamSelection(
|
||||
subtitles: subtitles,
|
||||
audios: audios,
|
||||
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
preferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
int resolveDefaultSourceIndex(
|
||||
List<EmbyRawMediaSource> sortedSources,
|
||||
String? rememberedMediaSourceId, {
|
||||
int fallback = 0,
|
||||
}) {
|
||||
if (rememberedMediaSourceId == null) return fallback;
|
||||
final idx = sortedSources.indexWhere((s) => s.Id == rememberedMediaSourceId);
|
||||
return idx >= 0 ? idx : fallback;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../core/contracts/tmdb.dart';
|
||||
import '../../providers/detail_palette_provider.dart';
|
||||
import '../../shared/mappers/tmdb_image_selector.dart';
|
||||
import '../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import '../../shared/utils/tmdb_cta_model.dart';
|
||||
import 'utils/detail_helpers.dart';
|
||||
import 'android/android_detail_layout.dart';
|
||||
import 'android/android_detail_colors.dart';
|
||||
import 'android/android_external_links_section.dart';
|
||||
import 'android/android_hero_actions.dart';
|
||||
import 'android/android_hero_info.dart';
|
||||
import 'android/android_overview_section.dart';
|
||||
import 'android/android_section_divider.dart';
|
||||
import 'android/android_stream_selector_row.dart';
|
||||
import 'detail_entry.dart';
|
||||
import 'model/detail_section_model.dart';
|
||||
import 'widgets/tmdb_cast_section.dart';
|
||||
import 'widgets/tmdb_recommendation_section.dart';
|
||||
|
||||
class TmdbDetailBodyAndroid extends ConsumerWidget {
|
||||
const TmdbDetailBodyAndroid({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.enriched,
|
||||
required this.imageBaseUrl,
|
||||
required this.language,
|
||||
required this.showSpinner,
|
||||
required this.scrollController,
|
||||
required this.scrollProgress,
|
||||
required this.primaryHit,
|
||||
required this.availabilityResolving,
|
||||
required this.isFavorite,
|
||||
required this.isPlayed,
|
||||
required this.subtitles,
|
||||
required this.audios,
|
||||
required this.selectedSubtitleIdx,
|
||||
required this.selectedAudioIdx,
|
||||
required this.onPlay,
|
||||
required this.onReplay,
|
||||
required this.onToggleFavorite,
|
||||
required this.onTogglePlayed,
|
||||
required this.onSelectSubtitleIndex,
|
||||
required this.onSelectAudioIndex,
|
||||
required this.hitCount,
|
||||
required this.hitStateSections,
|
||||
});
|
||||
|
||||
final TmdbDetailEntry entry;
|
||||
final TmdbEnrichedDetail? enriched;
|
||||
final String imageBaseUrl;
|
||||
final String language;
|
||||
final bool showSpinner;
|
||||
final ScrollController scrollController;
|
||||
final ValueNotifier<double> scrollProgress;
|
||||
final TmdbLibraryHit? primaryHit;
|
||||
final bool availabilityResolving;
|
||||
final bool isFavorite;
|
||||
final bool isPlayed;
|
||||
final List<EmbyRawMediaStream> subtitles;
|
||||
final List<EmbyRawMediaStream> audios;
|
||||
final int? selectedSubtitleIdx;
|
||||
final int selectedAudioIdx;
|
||||
final VoidCallback onPlay;
|
||||
final VoidCallback onReplay;
|
||||
final VoidCallback onToggleFavorite;
|
||||
final VoidCallback onTogglePlayed;
|
||||
final ValueChanged<int?> onSelectSubtitleIndex;
|
||||
final ValueChanged<int> onSelectAudioIndex;
|
||||
final int hitCount;
|
||||
final List<DetailSection> hitStateSections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mediaType = entry.mediaType;
|
||||
final detail = enriched?.detail;
|
||||
final title = (detail?.title.isNotEmpty ?? false)
|
||||
? detail!.title
|
||||
: (entry.seed?.title ?? '');
|
||||
|
||||
final posterPath = detail?.posterPath ?? entry.seed?.posterPath;
|
||||
final backdropPath = detail?.backdropPath ?? entry.seed?.backdropPath;
|
||||
final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath);
|
||||
final backdropUrl = TmdbImageUrl.backdrop(imageBaseUrl, backdropPath);
|
||||
final fallbackUrls = TmdbImageSelector.buildFallbackUrls(
|
||||
primaryUrl: backdropUrl,
|
||||
candidates: [posterUrl],
|
||||
);
|
||||
final paletteImageUrl = posterUrl ?? backdropUrl;
|
||||
final extractedBackgroundColor = paletteImageUrl == null
|
||||
? null
|
||||
: ref
|
||||
.watch(
|
||||
detailPaletteProvider(
|
||||
DetailPaletteRequest(url: paletteImageUrl),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
final detailBackgroundColor =
|
||||
extractedBackgroundColor ?? AndroidDetailColors.background;
|
||||
|
||||
final metaParts = <String>[];
|
||||
final year = yearOf(detail?.releaseDate);
|
||||
if (year != null) metaParts.add(year);
|
||||
final runtime = formatRuntimeMinutes(detail?.runtime);
|
||||
if (runtime != null) metaParts.add(runtime);
|
||||
|
||||
final genreNames = [
|
||||
for (final g in detail?.genres ?? const <TmdbGenre>[])
|
||||
if (g.name.isNotEmpty) g.name,
|
||||
];
|
||||
|
||||
final overview = (detail?.overview?.trim().isNotEmpty ?? false)
|
||||
? detail!.overview!.trim()
|
||||
: (entry.seed?.overview?.trim() ?? '');
|
||||
|
||||
final voteAverage = detail?.voteAverage ?? entry.seed?.voteAverage;
|
||||
|
||||
String? playLabel;
|
||||
bool showReplay = false;
|
||||
double? progressPercent;
|
||||
String? positionClock;
|
||||
bool canPlay = false;
|
||||
|
||||
if (!availabilityResolving) {
|
||||
final ctaLabel = buildTmdbCtaLabel(
|
||||
mediaType: mediaType,
|
||||
primaryHit: primaryHit,
|
||||
hitCount: hitCount,
|
||||
);
|
||||
if (ctaLabel != null && primaryHit != null) {
|
||||
canPlay = true;
|
||||
playLabel = ctaLabel;
|
||||
final pos = primaryHit!.playbackPositionTicks ?? 0;
|
||||
final rt = primaryHit!.runTimeTicks ?? 0;
|
||||
final hasProgress = pos > 0;
|
||||
progressPercent = (hasProgress && rt > 0)
|
||||
? (pos / rt * 100).clamp(0.0, 100.0)
|
||||
: null;
|
||||
positionClock = hasProgress ? formatTicksAsClock(pos) : null;
|
||||
showReplay = hasProgress;
|
||||
}
|
||||
}
|
||||
|
||||
final tmdbIdInt = detail?.id;
|
||||
final imdbIdStr = enriched?.imdbId;
|
||||
|
||||
final cast = enriched?.credits?.cast ?? const <TmdbCastMember>[];
|
||||
final recommendations =
|
||||
enriched?.recommendations?.results ?? const <TmdbRecommendation>[];
|
||||
|
||||
final sectionWidgets = <Widget>[
|
||||
for (final section in hitStateSections)
|
||||
if (section.id != 'tmdb_movie_divider' &&
|
||||
section.id != 'tmdb_tv_divider')
|
||||
section.child,
|
||||
];
|
||||
|
||||
sectionWidgets.add(
|
||||
TmdbCastSection(
|
||||
cast: cast,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
leading: const AndroidSectionDivider(),
|
||||
),
|
||||
);
|
||||
sectionWidgets.add(
|
||||
TmdbRecommendationSection(
|
||||
items: recommendations,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
fallbackMediaType: mediaType,
|
||||
leading: const AndroidSectionDivider(),
|
||||
),
|
||||
);
|
||||
sectionWidgets.add(
|
||||
ExternalLinksSection(
|
||||
imdbId: imdbIdStr,
|
||||
tmdbId: tmdbIdInt,
|
||||
tmdbMediaType: mediaType,
|
||||
leading: const AndroidSectionDivider(),
|
||||
),
|
||||
);
|
||||
|
||||
return AndroidDetailLayout(
|
||||
scrollController: scrollController,
|
||||
scrollProgress: scrollProgress,
|
||||
backdropUrl: backdropUrl,
|
||||
fallbackUrls: fallbackUrls,
|
||||
backgroundColor: detailBackgroundColor,
|
||||
navigationColor: createDetailNavigationColor(detailBackgroundColor),
|
||||
title: title,
|
||||
onBack: () => context.pop(),
|
||||
heroInfo: AndroidHeroInfo(
|
||||
posterUrl: posterUrl,
|
||||
title: title,
|
||||
rating: voteAverage,
|
||||
metaParts: metaParts,
|
||||
genres: genreNames,
|
||||
),
|
||||
heroActions: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AndroidHeroActions(
|
||||
playLabel: playLabel ?? '播放',
|
||||
playTrailingLabel: positionClock,
|
||||
progressPercent: progressPercent,
|
||||
isLoading: availabilityResolving,
|
||||
loadingLabel: '检测中',
|
||||
canPlay: canPlay || availabilityResolving,
|
||||
onPlay: canPlay ? onPlay : null,
|
||||
showReplay: showReplay,
|
||||
onReplay: onReplay,
|
||||
isPlayed: isPlayed,
|
||||
onTogglePlayed: onTogglePlayed,
|
||||
isFavorite: isFavorite,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
),
|
||||
if (canPlay)
|
||||
AndroidStreamSelectorRow(
|
||||
subtitles: subtitles,
|
||||
audios: audios,
|
||||
selectedSubtitleIdx: selectedSubtitleIdx,
|
||||
selectedAudioIdx: selectedAudioIdx,
|
||||
onSelectSubtitleIndex: onSelectSubtitleIndex,
|
||||
onSelectAudioIndex: onSelectAudioIndex,
|
||||
),
|
||||
],
|
||||
),
|
||||
overview: overview.isNotEmpty
|
||||
? AndroidOverviewSection(overview: overview)
|
||||
: null,
|
||||
sections: sectionWidgets,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../core/contracts/tmdb.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/last_played_version_store.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/tmdb_settings_provider.dart';
|
||||
import '../../providers/version_priority_provider.dart';
|
||||
import '../../shared/constants/breakpoints.dart';
|
||||
import '../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/utils/cross_server_search_key.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../player/player_launcher.dart';
|
||||
import '../../shared/utils/tmdb_cta_model.dart';
|
||||
import '../../shared/utils/tmdb_primary_hit.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_skeleton.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import 'widgets/additional_parts_section.dart';
|
||||
import 'widgets/episode_list_section.dart';
|
||||
import 'adapter/tmdb_detail_view_builder.dart';
|
||||
import 'android/android_detail_geometry.dart';
|
||||
import 'detail_entry.dart';
|
||||
import 'model/detail_section_model.dart';
|
||||
import 'model/episode_auto_select_args.dart';
|
||||
import 'model/stream_selection.dart';
|
||||
import 'tmdb_detail_body_android.dart';
|
||||
import 'widgets/detail_immersive_scaffold.dart';
|
||||
import 'widgets/detail_nav_bar.dart';
|
||||
import 'widgets/detail_scroll_progress.dart';
|
||||
import 'widgets/detail_view_renderer.dart';
|
||||
import 'widgets/hero_action_buttons.dart';
|
||||
import 'widgets/source_selection_section.dart';
|
||||
import 'widgets/stream_selector_actions.dart';
|
||||
|
||||
const Color _kBg = Color(0xFF08090c);
|
||||
|
||||
|
||||
const double _kHPad = 32.0;
|
||||
|
||||
|
||||
class TmdbDetailView extends ConsumerStatefulWidget {
|
||||
final TmdbDetailEntry entry;
|
||||
|
||||
const TmdbDetailView({super.key, required this.entry});
|
||||
|
||||
@override
|
||||
ConsumerState<TmdbDetailView> createState() => _TmdbDetailViewState();
|
||||
}
|
||||
|
||||
class _TmdbDetailViewState extends ConsumerState<TmdbDetailView> {
|
||||
static const _sectionDivider = FDivider(
|
||||
style: .delta(
|
||||
color: Color(0x1AFFFFFF),
|
||||
padding: .value(EdgeInsets.only(top: 24)),
|
||||
),
|
||||
);
|
||||
|
||||
final _scroll = ScrollController();
|
||||
final _scrollProgress = ValueNotifier<double>(0);
|
||||
|
||||
bool? _favoriteOverride;
|
||||
bool? _playedOverride;
|
||||
|
||||
|
||||
String? _overrideHitKey;
|
||||
|
||||
int _selectedSourceIdx = 0;
|
||||
CrossServerSourceCard? _selectedCrossServerCard;
|
||||
EmbyRawItem? _selectedTvEpisode;
|
||||
int? _selectedTvEpisodeSeasonNumber;
|
||||
String? _selectedTvEpisodeHitKey;
|
||||
int? _selectedSubtitleIdx;
|
||||
int _selectedAudioIdx = 0;
|
||||
|
||||
|
||||
String? _pendingMediaSourceId;
|
||||
|
||||
|
||||
String? _versionMemoryQueriedKey;
|
||||
|
||||
|
||||
String? _playbackOriginServerId;
|
||||
|
||||
|
||||
int? _positionTicksOverride;
|
||||
String? _positionOverrideItemKey;
|
||||
|
||||
int? _positionOverrideFor(String? serverId, String? itemId) {
|
||||
if (_positionTicksOverride == null) return null;
|
||||
if (_positionOverrideItemKey != '$serverId|$itemId') return null;
|
||||
return _positionTicksOverride;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(_onScroll);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
_scrollProgress.value = computeDetailScrollProgress(
|
||||
context,
|
||||
_scroll.offset,
|
||||
collapseExtent: Platform.isAndroid
|
||||
? computeAndroidDetailCollapseExtent(context)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TmdbDetailView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final old = oldWidget.entry;
|
||||
final next = widget.entry;
|
||||
if (old.mediaType != next.mediaType || old.tmdbId != next.tmdbId) {
|
||||
setState(() {
|
||||
_favoriteOverride = null;
|
||||
_playedOverride = null;
|
||||
_overrideHitKey = null;
|
||||
_selectedSourceIdx = 0;
|
||||
_selectedCrossServerCard = null;
|
||||
_selectedTvEpisode = null;
|
||||
_selectedTvEpisodeSeasonNumber = null;
|
||||
_selectedTvEpisodeHitKey = null;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
_pendingMediaSourceId = null;
|
||||
_versionMemoryQueriedKey = null;
|
||||
_positionTicksOverride = null;
|
||||
_positionOverrideItemKey = null;
|
||||
});
|
||||
_scrollProgress.value = 0;
|
||||
if (_scroll.hasClients) _scroll.jumpTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.removeListener(_onScroll);
|
||||
_scroll.dispose();
|
||||
_scrollProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
void _selectLocalSource(int index) {
|
||||
setState(() {
|
||||
_selectedSourceIdx = index;
|
||||
_selectedCrossServerCard = null;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
_pendingMediaSourceId = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _selectCrossServerSource(CrossServerSourceCard card) {
|
||||
setState(() {
|
||||
_selectedCrossServerCard = card;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
_pendingMediaSourceId = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _selectTvEpisode(EmbyRawItem episode, int? seasonNumber, String hitKey) {
|
||||
setState(() {
|
||||
_selectedTvEpisode = episode;
|
||||
_selectedTvEpisodeSeasonNumber = seasonNumber;
|
||||
_selectedTvEpisodeHitKey = hitKey;
|
||||
_selectedSourceIdx = 0;
|
||||
_selectedCrossServerCard = null;
|
||||
_selectedSubtitleIdx = null;
|
||||
_selectedAudioIdx = 0;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<void> _playRestoringActiveServer({
|
||||
required String serverId,
|
||||
required String itemId,
|
||||
String? mediaSourceId,
|
||||
int preferredSubtitleStreamIndex = -1,
|
||||
int? preferredAudioStreamIndex,
|
||||
bool fromStart = false,
|
||||
int? startPositionTicks,
|
||||
String? backdropUrl,
|
||||
}) async {
|
||||
final originServerId = ref.read(activeSessionProvider)?.serverId;
|
||||
setState(() => _playbackOriginServerId = originServerId);
|
||||
PlayerExitResult? exitResult;
|
||||
try {
|
||||
exitResult = await playFromTmdbOnServer(
|
||||
context,
|
||||
ref,
|
||||
serverId: serverId,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
preferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
startPositionTicks: startPositionTicks,
|
||||
fromStart: fromStart,
|
||||
backdropUrl: backdropUrl,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
if (originServerId != null &&
|
||||
originServerId.isNotEmpty &&
|
||||
ref.read(activeSessionProvider)?.serverId != originServerId) {
|
||||
try {
|
||||
await ref
|
||||
.read(sessionProvider.notifier)
|
||||
.switchSession(originServerId);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) setState(() => _playbackOriginServerId = null);
|
||||
}
|
||||
}
|
||||
final positionTicks = exitResult?.positionTicks;
|
||||
if (!mounted || positionTicks == null || positionTicks <= 0) return;
|
||||
final exitServerId = exitResult!.serverId;
|
||||
final exitItemId = exitResult.itemId;
|
||||
setState(() {
|
||||
_positionTicksOverride = positionTicks;
|
||||
_positionOverrideItemKey = '$exitServerId|$exitItemId';
|
||||
final ep = _selectedTvEpisode;
|
||||
if (ep != null && ep.Id == exitItemId) {
|
||||
_selectedTvEpisode = ep.copyWith(
|
||||
UserData: (ep.UserData ?? const EmbyRawUserData()).copyWith(
|
||||
PlaybackPositionTicks: positionTicks,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _selectSubtitleIndex(int? index) {
|
||||
setState(() => _selectedSubtitleIdx = index);
|
||||
}
|
||||
|
||||
void _selectAudioIndex(int index) {
|
||||
setState(() => _selectedAudioIdx = index);
|
||||
}
|
||||
|
||||
static String _hitKeyOf(TmdbLibraryHit hit) =>
|
||||
'${hit.serverId}|${hit.itemId}';
|
||||
|
||||
|
||||
Future<void> _toggleTmdbFavorite(TmdbLibraryHit hit, bool current) async {
|
||||
final next = !current;
|
||||
setState(() {
|
||||
_favoriteOverride = next;
|
||||
_overrideHitKey = _hitKeyOf(hit);
|
||||
});
|
||||
try {
|
||||
final uc = await ref.read(setFavoriteUseCaseProvider.future);
|
||||
final res = await uc.executeForServer(
|
||||
serverId: hit.serverId,
|
||||
input: FavoriteSetReq(itemId: hit.itemId, isFavorite: next),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _favoriteOverride = res.isFavorite);
|
||||
ref.invalidate(
|
||||
tmdbServerScopedDetailProvider((
|
||||
serverId: hit.serverId,
|
||||
itemId: hit.itemId,
|
||||
)),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _favoriteOverride = current);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _toggleTmdbPlayed(TmdbLibraryHit hit, bool current) async {
|
||||
final next = !current;
|
||||
setState(() {
|
||||
_playedOverride = next;
|
||||
_overrideHitKey = _hitKeyOf(hit);
|
||||
});
|
||||
try {
|
||||
final uc = await ref.read(setPlayedUseCaseProvider.future);
|
||||
final res = await uc.executeForServer(
|
||||
serverId: hit.serverId,
|
||||
input: PlayedSetReq(itemId: hit.itemId, played: next),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _playedOverride = res.played);
|
||||
ref.invalidate(
|
||||
tmdbServerScopedDetailProvider((
|
||||
serverId: hit.serverId,
|
||||
itemId: hit.itemId,
|
||||
)),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _playedOverride = current);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _buildTmdbDetail(widget.entry);
|
||||
}
|
||||
|
||||
Widget _buildTmdbDetail(TmdbDetailEntry entry) {
|
||||
final key = (tmdbId: entry.tmdbId, mediaType: entry.mediaType);
|
||||
final settings = ref.watch(tmdbSettingsProvider).value;
|
||||
final imageBaseUrl = settings?.effectiveImageBaseUrl ?? '';
|
||||
final language = settings?.language ?? 'zh-CN';
|
||||
final asyncDetail = ref.watch(tmdbEnrichedDetailProvider(key));
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: asyncDetail.when(
|
||||
loading: () => _buildTmdbContent(
|
||||
entry: entry,
|
||||
enriched: null,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
language: language,
|
||||
showSpinner: true,
|
||||
),
|
||||
error: (_, _) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: AppErrorState(
|
||||
title: '加载失败',
|
||||
message: '无法获取该内容的 TMDB 详情',
|
||||
onRetry: () => ref.invalidate(tmdbEnrichedDetailProvider(key)),
|
||||
),
|
||||
),
|
||||
data: (enriched) {
|
||||
if (enriched == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: const Center(
|
||||
child: EmptyState(
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: '暂无详情',
|
||||
message: '请检查 TMDB 配置后重试',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildTmdbContent(
|
||||
entry: entry,
|
||||
enriched: enriched,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
language: language,
|
||||
showSpinner: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final detail = asyncDetail.value?.detail;
|
||||
final title = (detail?.title.isNotEmpty ?? false)
|
||||
? detail!.title
|
||||
: (entry.seed?.title ?? '');
|
||||
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
backgroundColor: _kBg,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: asyncDetail.when(
|
||||
loading: () => _buildTmdbContent(
|
||||
entry: entry,
|
||||
enriched: null,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
language: language,
|
||||
showSpinner: true,
|
||||
),
|
||||
error: (_, _) => AppErrorState(
|
||||
title: '加载失败',
|
||||
message: '无法获取该内容的 TMDB 详情',
|
||||
onRetry: () =>
|
||||
ref.invalidate(tmdbEnrichedDetailProvider(key)),
|
||||
),
|
||||
data: (enriched) {
|
||||
if (enriched == null) {
|
||||
return const Center(
|
||||
child: EmptyState(
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: '暂无详情',
|
||||
message: '请检查 TMDB 配置后重试',
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildTmdbContent(
|
||||
entry: entry,
|
||||
enriched: enriched,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
language: language,
|
||||
showSpinner: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: DetailNavBar(
|
||||
scrollProgress: _scrollProgress,
|
||||
title: title,
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTmdbContent({
|
||||
required TmdbDetailEntry entry,
|
||||
required TmdbEnrichedDetail? enriched,
|
||||
required String imageBaseUrl,
|
||||
required String language,
|
||||
required bool showSpinner,
|
||||
}) {
|
||||
final mediaType = entry.mediaType;
|
||||
final tmdbId = entry.tmdbId;
|
||||
final detail = enriched?.detail;
|
||||
final title = (detail?.title.isNotEmpty ?? false)
|
||||
? detail!.title
|
||||
: (entry.seed?.title ?? '');
|
||||
|
||||
final imdbIdForLookup = mediaType == 'movie' ? enriched?.imdbId : null;
|
||||
final detailBackdropUrl = TmdbImageUrl.backdrop(
|
||||
imageBaseUrl,
|
||||
detail?.backdropPath ?? entry.seed?.backdropPath,
|
||||
);
|
||||
|
||||
final availabilityTmdbAsync = ref.watch(
|
||||
tmdbLibraryAvailabilityProvider((
|
||||
mediaType: mediaType,
|
||||
tmdbId: tmdbId,
|
||||
imdbId: '',
|
||||
)),
|
||||
);
|
||||
final tmdbResultEmpty = availabilityTmdbAsync.value?.isEmpty == true;
|
||||
final needsImdbFallback = imdbIdForLookup != null && tmdbResultEmpty;
|
||||
final availabilityImdbAsync = needsImdbFallback
|
||||
? ref.watch(
|
||||
tmdbLibraryAvailabilityProvider((
|
||||
mediaType: mediaType,
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbIdForLookup,
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
final availabilityAsync = availabilityImdbAsync ?? availabilityTmdbAsync;
|
||||
final hits = availabilityAsync.value ?? const <TmdbLibraryHit>[];
|
||||
final availabilityResolving =
|
||||
(availabilityTmdbAsync.isLoading &&
|
||||
availabilityTmdbAsync.value == null) ||
|
||||
(availabilityImdbAsync?.isLoading == true &&
|
||||
availabilityImdbAsync?.value == null) ||
|
||||
(mediaType == 'movie' && enriched == null && tmdbResultEmpty);
|
||||
final liveActiveServerId = ref.watch(activeSessionProvider)?.serverId;
|
||||
final activeServerId = _playbackOriginServerId ?? liveActiveServerId;
|
||||
final rawPrimaryHit = selectPrimaryTmdbHit(
|
||||
hits,
|
||||
activeServerId: activeServerId,
|
||||
);
|
||||
final tvRecentHit = (rawPrimaryHit != null && mediaType == 'tv')
|
||||
? ref
|
||||
.watch(
|
||||
tmdbTvRecentHitProvider((
|
||||
mediaType: mediaType,
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbIdForLookup ?? '',
|
||||
)),
|
||||
)
|
||||
.value
|
||||
: null;
|
||||
final primaryHit = tvRecentHit ?? rawPrimaryHit;
|
||||
|
||||
AsyncValue<MediaDetailRes>? scopedDetailAsync;
|
||||
EmbyRawItem? serverScopedItem;
|
||||
if (primaryHit != null) {
|
||||
final scopedKey = (
|
||||
serverId: primaryHit.serverId,
|
||||
itemId: primaryHit.itemId,
|
||||
);
|
||||
scopedDetailAsync = ref.watch(tmdbServerScopedDetailProvider(scopedKey));
|
||||
serverScopedItem = scopedDetailAsync?.value?.base;
|
||||
}
|
||||
if (_overrideHitKey != null &&
|
||||
(primaryHit == null || _overrideHitKey != _hitKeyOf(primaryHit))) {
|
||||
_favoriteOverride = null;
|
||||
_playedOverride = null;
|
||||
_overrideHitKey = null;
|
||||
}
|
||||
final isFavorite =
|
||||
_favoriteOverride ?? (serverScopedItem?.UserData?.IsFavorite ?? false);
|
||||
final isPlayed =
|
||||
_playedOverride ?? (serverScopedItem?.UserData?.Played ?? false);
|
||||
|
||||
final isMovieHit = primaryHit != null && mediaType == 'movie';
|
||||
final isTvHit = primaryHit != null && mediaType == 'tv';
|
||||
final primaryHitKey = primaryHit == null ? null : _hitKeyOf(primaryHit);
|
||||
final selectedTvEpisode =
|
||||
isTvHit && _selectedTvEpisodeHitKey == primaryHitKey
|
||||
? _selectedTvEpisode
|
||||
: null;
|
||||
final selectedTvEpisodeSeasonNumber = selectedTvEpisode == null
|
||||
? null
|
||||
: _selectedTvEpisodeSeasonNumber;
|
||||
|
||||
final tvAnchorForResume = isTvHit ? primaryHit : null;
|
||||
final tvResumeAsync = tvAnchorForResume == null
|
||||
? null
|
||||
: ref.watch(
|
||||
seriesResumeTargetProvider((
|
||||
serverId: tvAnchorForResume.serverId,
|
||||
seriesId: tvAnchorForResume.itemId,
|
||||
)),
|
||||
);
|
||||
final tvAutoSelect = EpisodeAutoSelectArgs.fromResume(tvResumeAsync);
|
||||
|
||||
final priorities =
|
||||
ref.watch(versionPriorityProvider).value?.activePriorities ??
|
||||
const <VersionPriority>[];
|
||||
final localSourceItem = isMovieHit ? serverScopedItem : selectedTvEpisode;
|
||||
final sortedSources = localSourceItem != null
|
||||
? sortMediaSources(mediaSourcesOfItem(localSourceItem), priorities)
|
||||
: const <EmbyRawMediaSource>[];
|
||||
|
||||
final memServerId = primaryHit?.serverId;
|
||||
final memItemId = localSourceItem?.Id;
|
||||
if (memServerId != null &&
|
||||
memServerId.isNotEmpty &&
|
||||
memItemId != null &&
|
||||
memItemId.isNotEmpty) {
|
||||
final memKey = '$memServerId:$memItemId';
|
||||
if (memKey != _versionMemoryQueriedKey) {
|
||||
_versionMemoryQueriedKey = memKey;
|
||||
final store = ref.read(lastPlayedVersionStoreProvider);
|
||||
unawaited(
|
||||
store
|
||||
.get(serverId: memServerId, itemId: memItemId)
|
||||
.then((id) {
|
||||
if (!mounted || id == null) return;
|
||||
if (_versionMemoryQueriedKey != memKey) return;
|
||||
if (_selectedSourceIdx != 0) return;
|
||||
setState(() => _pendingMediaSourceId = id);
|
||||
})
|
||||
.onError((e, s) {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
final pendingId = _pendingMediaSourceId;
|
||||
if (pendingId != null && sortedSources.isNotEmpty) {
|
||||
final idx = resolveDefaultSourceIndex(
|
||||
sortedSources,
|
||||
pendingId,
|
||||
fallback: -1,
|
||||
);
|
||||
if (idx >= 0 && idx != _selectedSourceIdx) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedSourceIdx = idx;
|
||||
_pendingMediaSourceId = null;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_pendingMediaSourceId = null;
|
||||
}
|
||||
}
|
||||
|
||||
final effectiveCrossServerCard = isTvHit && selectedTvEpisode == null
|
||||
? null
|
||||
: _selectedCrossServerCard;
|
||||
|
||||
final localSourceId =
|
||||
effectiveCrossServerCard == null &&
|
||||
_selectedSourceIdx < sortedSources.length
|
||||
? sortedSources[_selectedSourceIdx].Id
|
||||
: null;
|
||||
final selectedMediaSource = resolveActiveMediaSource(
|
||||
effectiveCrossServerCard,
|
||||
sortedSources,
|
||||
_selectedSourceIdx,
|
||||
);
|
||||
final streamSelection = resolveStreamSelection(
|
||||
selectedMediaSource,
|
||||
_selectedSubtitleIdx,
|
||||
_selectedAudioIdx,
|
||||
);
|
||||
final subtitles = streamSelection.subtitles;
|
||||
final audios = streamSelection.audios;
|
||||
final preferredSubtitleStreamIndex =
|
||||
streamSelection.preferredSubtitleStreamIndex;
|
||||
final preferredAudioStreamIndex = streamSelection.preferredAudioStreamIndex;
|
||||
|
||||
void play(bool fromStart) {
|
||||
final card = effectiveCrossServerCard;
|
||||
if (card != null) {
|
||||
final pos =
|
||||
selectedTvEpisode?.UserData?.PlaybackPositionTicks ??
|
||||
_positionOverrideFor(card.serverId, card.landingItemId) ??
|
||||
_positionOverrideFor(primaryHit?.serverId, primaryHit?.itemId) ??
|
||||
primaryHit?.playbackPositionTicks;
|
||||
final resumeTicks = (!fromStart && pos != null && pos > 0) ? pos : null;
|
||||
unawaited(
|
||||
_playRestoringActiveServer(
|
||||
serverId: card.serverId,
|
||||
itemId: card.landingItemId,
|
||||
mediaSourceId: card.mediaSourceId,
|
||||
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
preferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
startPositionTicks: resumeTicks,
|
||||
fromStart: resumeTicks == null,
|
||||
backdropUrl: detailBackdropUrl,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (primaryHit == null) return;
|
||||
final localItemId = selectedTvEpisode?.Id ?? primaryHit.itemId;
|
||||
final localResume = fromStart
|
||||
? null
|
||||
: _positionOverrideFor(primaryHit.serverId, localItemId);
|
||||
unawaited(
|
||||
_playRestoringActiveServer(
|
||||
serverId: primaryHit.serverId,
|
||||
itemId: localItemId,
|
||||
mediaSourceId: localSourceId,
|
||||
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
preferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
startPositionTicks: localResume,
|
||||
fromStart: fromStart,
|
||||
backdropUrl: detailBackdropUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final imdb = imdbIdForLookup?.trim() ?? '';
|
||||
final crossServerReq = CrossServerSearchReq(
|
||||
anchorType: 'Movie',
|
||||
itemName: title,
|
||||
providerIds: {'Tmdb': tmdbId, if (imdb.isNotEmpty) 'Imdb': imdb},
|
||||
);
|
||||
final tvEpisodeReq = selectedTvEpisode == null || serverScopedItem == null
|
||||
? null
|
||||
: buildEpisodeCrossServerReq(
|
||||
episode: selectedTvEpisode,
|
||||
seriesItem: serverScopedItem,
|
||||
seasonNumberOverride: selectedTvEpisodeSeasonNumber,
|
||||
);
|
||||
|
||||
final hitStateSections = <DetailSection>[
|
||||
if (isMovieHit) ...[
|
||||
DetailSection(
|
||||
id: 'tmdb_movie_source',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
|
||||
child: SourceSelectionSection(
|
||||
req: crossServerReq,
|
||||
localSources: sortedSources,
|
||||
selectedSourceIdx: _selectedSourceIdx,
|
||||
selectedCrossServerCard: effectiveCrossServerCard,
|
||||
currentServerName: primaryHit.serverName,
|
||||
excludedServerIdOverride: primaryHit.serverId,
|
||||
localMediaInfoItem: localSourceItem,
|
||||
showLoadingWhenEmpty: true,
|
||||
externalLoading: scopedDetailAsync?.isLoading ?? false,
|
||||
loadingText: '正在查找可用版本…',
|
||||
onLocalSourceTap: (src, index) => _selectLocalSource(index),
|
||||
onCrossServerSourceTap: _selectCrossServerSource,
|
||||
),
|
||||
),
|
||||
),
|
||||
DetailSection(
|
||||
id: 'tmdb_movie_additional_parts',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
|
||||
child: AdditionalPartsSection(
|
||||
itemId: primaryHit.itemId,
|
||||
embedded: true,
|
||||
serverId: primaryHit.serverId,
|
||||
onItemTap: (item) => unawaited(
|
||||
_playRestoringActiveServer(
|
||||
serverId: primaryHit.serverId,
|
||||
itemId: item.Id,
|
||||
backdropUrl: detailBackdropUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const DetailSection(id: 'tmdb_movie_divider', child: _sectionDivider),
|
||||
],
|
||||
if (isTvHit) ...[
|
||||
DetailSection(
|
||||
id: 'tmdb_tv_episodes',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
|
||||
child: EpisodeListSection(
|
||||
seriesId: primaryHit.itemId,
|
||||
tmdbSeriesId: tmdbId,
|
||||
serverId: primaryHit.serverId,
|
||||
embedded: true,
|
||||
autoSelectFirstEpisode: tvAutoSelect.autoSelectFirstEpisode,
|
||||
autoSelectEpisodeId: tvAutoSelect.autoSelectEpisodeId,
|
||||
autoSelectSeasonId: tvAutoSelect.autoSelectSeasonId,
|
||||
currentEpisodeId: selectedTvEpisode?.Id,
|
||||
onEpisodeTap: (episode) =>
|
||||
_selectTvEpisode(episode, null, primaryHitKey!),
|
||||
onEpisodeTapWithSeason: (episode, seasonNumber) =>
|
||||
_selectTvEpisode(episode, seasonNumber, primaryHitKey!),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (tvEpisodeReq != null)
|
||||
DetailSection(
|
||||
id: 'tmdb_tv_source',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
|
||||
child: SourceSelectionSection(
|
||||
req: tvEpisodeReq,
|
||||
localSources: sortedSources,
|
||||
selectedSourceIdx: _selectedSourceIdx,
|
||||
selectedCrossServerCard: effectiveCrossServerCard,
|
||||
currentServerName: primaryHit.serverName,
|
||||
excludedServerIdOverride: primaryHit.serverId,
|
||||
localMediaInfoItem: localSourceItem,
|
||||
showLoadingWhenEmpty: true,
|
||||
loadingText: '正在查找该集的可用版本…',
|
||||
onLocalSourceTap: (src, index) => _selectLocalSource(index),
|
||||
onCrossServerSourceTap: _selectCrossServerSource,
|
||||
),
|
||||
),
|
||||
),
|
||||
const DetailSection(id: 'tmdb_tv_divider', child: _sectionDivider),
|
||||
],
|
||||
];
|
||||
|
||||
final heroActions = _buildHeroActions(
|
||||
mediaType: mediaType,
|
||||
primaryHit: primaryHit,
|
||||
hitCount: hits.length,
|
||||
availabilityResolving: availabilityResolving,
|
||||
isFavorite: isFavorite,
|
||||
isPlayed: isPlayed,
|
||||
subtitles: subtitles,
|
||||
audios: audios,
|
||||
onPlay: () => play(false),
|
||||
onReplay: () => play(true),
|
||||
);
|
||||
|
||||
final viewState = buildTmdbDetailViewState(
|
||||
mediaType: mediaType,
|
||||
enriched: enriched,
|
||||
seed: entry.seed,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
language: language,
|
||||
heroActions: heroActions,
|
||||
hitStateSections: hitStateSections,
|
||||
showSpinner: showSpinner,
|
||||
);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
return TmdbDetailBodyAndroid(
|
||||
entry: entry,
|
||||
enriched: enriched,
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
language: language,
|
||||
showSpinner: showSpinner,
|
||||
scrollController: _scroll,
|
||||
scrollProgress: _scrollProgress,
|
||||
primaryHit: primaryHit,
|
||||
availabilityResolving: availabilityResolving,
|
||||
isFavorite: isFavorite,
|
||||
isPlayed: isPlayed,
|
||||
subtitles: subtitles,
|
||||
audios: audios,
|
||||
selectedSubtitleIdx: _selectedSubtitleIdx,
|
||||
selectedAudioIdx: _selectedAudioIdx,
|
||||
onPlay: () => play(false),
|
||||
onReplay: () => play(true),
|
||||
onToggleFavorite: primaryHit != null
|
||||
? () => unawaited(_toggleTmdbFavorite(primaryHit, isFavorite))
|
||||
: () {},
|
||||
onTogglePlayed: primaryHit != null
|
||||
? () => unawaited(_toggleTmdbPlayed(primaryHit, isPlayed))
|
||||
: () {},
|
||||
onSelectSubtitleIndex: _selectSubtitleIndex,
|
||||
onSelectAudioIndex: _selectAudioIndex,
|
||||
hitCount: hits.length,
|
||||
hitStateSections: hitStateSections,
|
||||
);
|
||||
}
|
||||
|
||||
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
|
||||
return DetailImmersiveScaffold(
|
||||
scrollController: _scroll,
|
||||
scrollProgress: _scrollProgress,
|
||||
backdropUrl: viewState.image.backdropUrl,
|
||||
fallbackUrls: viewState.image.fallbackUrls,
|
||||
embyBaseUrl: viewState.image.embyBaseUrl,
|
||||
imageHeaders: viewState.image.imageHeaders,
|
||||
compact: isCompact,
|
||||
horizontalPadding: isCompact ? 16 : _kHPad,
|
||||
child: DetailViewRenderer(state: viewState),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget? _buildHeroActions({
|
||||
required String mediaType,
|
||||
required TmdbLibraryHit? primaryHit,
|
||||
required int hitCount,
|
||||
required bool availabilityResolving,
|
||||
required bool isFavorite,
|
||||
required bool isPlayed,
|
||||
required List<EmbyRawMediaStream> subtitles,
|
||||
required List<EmbyRawMediaStream> audios,
|
||||
required VoidCallback onPlay,
|
||||
required VoidCallback onReplay,
|
||||
}) {
|
||||
final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
|
||||
final btnH = compact ? 44.0 : 52.0;
|
||||
|
||||
if (availabilityResolving) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: AppSkeleton(height: btnH, radius: 12),
|
||||
);
|
||||
}
|
||||
|
||||
final ctaLabel = buildTmdbCtaLabel(
|
||||
mediaType: mediaType,
|
||||
primaryHit: primaryHit,
|
||||
hitCount: hitCount,
|
||||
);
|
||||
final hit = primaryHit;
|
||||
if (ctaLabel == null || hit == null) return null;
|
||||
|
||||
final pos =
|
||||
_positionOverrideFor(hit.serverId, hit.itemId) ??
|
||||
hit.playbackPositionTicks ??
|
||||
0;
|
||||
final runtime = hit.runTimeTicks ?? 0;
|
||||
final hasProgress = pos > 0;
|
||||
final progressPercent = (hasProgress && runtime > 0)
|
||||
? (pos / runtime * 100).clamp(0.0, 100.0)
|
||||
: null;
|
||||
final positionClock = hasProgress ? formatTicksAsClock(pos) : null;
|
||||
final extraActions = buildStreamSelectorPills(
|
||||
subtitles: subtitles,
|
||||
audios: audios,
|
||||
selectedSubtitleIdx: _selectedSubtitleIdx,
|
||||
selectedAudioIdx: _selectedAudioIdx,
|
||||
onSelectSubtitle: _selectSubtitleIndex,
|
||||
onSelectAudio: _selectAudioIndex,
|
||||
height: btnH,
|
||||
);
|
||||
|
||||
return DetailHeroActions(
|
||||
showReplay: hasProgress,
|
||||
onReplay: onReplay,
|
||||
isPlayed: isPlayed,
|
||||
onTogglePlayed: () => _toggleTmdbPlayed(hit, isPlayed),
|
||||
isFavorite: isFavorite,
|
||||
onToggleFavorite: () => _toggleTmdbFavorite(hit, isFavorite),
|
||||
extraIconActions: extraActions,
|
||||
onPlay: onPlay,
|
||||
playLabel: ctaLabel,
|
||||
progressPercent: progressPercent,
|
||||
trailingLabel: positionClock,
|
||||
compact: compact,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
String? yearOf(String? date) {
|
||||
if (date == null || date.length < 4) return null;
|
||||
return date.substring(0, 4);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../providers/di_providers.dart';
|
||||
import '../../../providers/emby_headers_provider.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../shared/mappers/media_image_url.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/media_poster_card.dart';
|
||||
import 'section_container.dart';
|
||||
|
||||
|
||||
typedef AdditionalPartTap = void Function(EmbyRawItem item);
|
||||
|
||||
class AdditionalPartsSection extends ConsumerWidget {
|
||||
final String itemId;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final String? serverId;
|
||||
|
||||
|
||||
final AdditionalPartTap? onItemTap;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const AdditionalPartsSection({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
this.embedded = false,
|
||||
this.serverId,
|
||||
this.onItemTap,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scopedServerId = serverId;
|
||||
|
||||
final AsyncValue<List<EmbyRawItem>> asyncItems;
|
||||
final String? baseUrl;
|
||||
final String? token;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
if (scopedServerId != null) {
|
||||
asyncItems = ref.watch(
|
||||
tmdbServerScopedAdditionalPartsProvider((
|
||||
serverId: scopedServerId,
|
||||
itemId: itemId,
|
||||
)),
|
||||
);
|
||||
final auth = ref
|
||||
.watch(tmdbServerScopedAuthProvider(scopedServerId))
|
||||
.value;
|
||||
baseUrl = auth?.baseUrl;
|
||||
token = auth?.token;
|
||||
imageHeaders = auth?.imageHeaders;
|
||||
} else {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
asyncItems = ref.watch(additionalPartsDataProvider(itemId));
|
||||
baseUrl = session.serverUrl;
|
||||
token = session.token;
|
||||
imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
}
|
||||
|
||||
return asyncItems.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stackTrace) => const SizedBox.shrink(),
|
||||
data: (items) {
|
||||
if (items.isEmpty || baseUrl == null || token == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final hPad = SectionContainer.hPadOf(context);
|
||||
final resolvedBaseUrl = baseUrl;
|
||||
final resolvedToken = token;
|
||||
|
||||
final isAndroid = Platform.isAndroid;
|
||||
return SectionContainer(
|
||||
title: '附加片段',
|
||||
embedded: embedded,
|
||||
leadingDivider: embedded,
|
||||
leading: leading,
|
||||
child: SizedBox(
|
||||
height: isAndroid ? 210 : 300,
|
||||
child: Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, controller) => ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final item = items[i];
|
||||
final imageUrl = EmbyImageUrl.primary(
|
||||
baseUrl: resolvedBaseUrl,
|
||||
token: resolvedToken,
|
||||
item: item,
|
||||
maxHeight: 480,
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i < items.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: MediaPosterCard(
|
||||
item: item,
|
||||
imageUrl: imageUrl,
|
||||
width: isAndroid ? 105 : 160,
|
||||
showHoverPlayIcon: false,
|
||||
enableContextMenu: false,
|
||||
imageHeaders: imageHeaders,
|
||||
onTap: () {
|
||||
final tap = onItemTap;
|
||||
if (tap != null) {
|
||||
tap(item);
|
||||
} else {
|
||||
context.pushReplacement(
|
||||
'/media/${item.Id}',
|
||||
extra: item,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
|
||||
|
||||
class DetailBackdrop extends StatefulWidget {
|
||||
const DetailBackdrop({
|
||||
super.key,
|
||||
required this.scrollProgress,
|
||||
required this.backdropUrl,
|
||||
required this.fallbackUrls,
|
||||
required this.embyBaseUrl,
|
||||
required this.imageHeaders,
|
||||
this.gradientEndColor = const Color(0xFF08090c),
|
||||
});
|
||||
|
||||
final ValueNotifier<double> scrollProgress;
|
||||
final String backdropUrl;
|
||||
final List<String> fallbackUrls;
|
||||
|
||||
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
final Color gradientEndColor;
|
||||
|
||||
@override
|
||||
State<DetailBackdrop> createState() => _DetailBackdropState();
|
||||
}
|
||||
|
||||
class _DetailBackdropState extends State<DetailBackdrop> {
|
||||
bool _backdropFailed = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DetailBackdrop oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.backdropUrl != widget.backdropUrl) {
|
||||
_backdropFailed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _backdropFailed
|
||||
? _buildFallbackGradient()
|
||||
: RepaintBoundary(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: widget.scrollProgress,
|
||||
builder: (_, p, child) {
|
||||
final maxSigma = Platform.isAndroid ? 6.0 : 10.0;
|
||||
var sigma = p > 0.01 ? p * maxSigma : 0.0;
|
||||
if (Platform.isAndroid && sigma > 0) {
|
||||
sigma = (sigma / 1.5).roundToDouble() * 1.5;
|
||||
}
|
||||
final blurred = ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: sigma,
|
||||
sigmaY: sigma,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
if (Platform.isAndroid) return blurred;
|
||||
return Transform.scale(
|
||||
scale: 1.0 + p * 0.15,
|
||||
child: blurred,
|
||||
);
|
||||
},
|
||||
child: _buildImageStack(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: widget.scrollProgress,
|
||||
builder: (_, p, _) {
|
||||
return IgnorePointer(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.10 + p * 0.3),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(child: _buildBottomGradient(context)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildBottomGradient(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
final coverage = (size.width * 9.0 / 16.0 / size.height).clamp(0.0, 1.0);
|
||||
|
||||
|
||||
final rampStart = coverage >= 0.95
|
||||
? 0.5
|
||||
: (coverage * 0.55).clamp(0.1, 0.5);
|
||||
final rampEnd = coverage >= 0.95
|
||||
? 0.75
|
||||
: (coverage * 0.85).clamp(0.25, 0.75);
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0.0, rampStart, rampEnd, 1.0],
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
widget.gradientEndColor.withValues(alpha: 0.85),
|
||||
widget.gradientEndColor,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFallbackGradient() {
|
||||
return IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
colors: [
|
||||
widget.gradientEndColor.withValues(alpha: 0.5),
|
||||
widget.gradientEndColor.withValues(alpha: 0.85),
|
||||
widget.gradientEndColor,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageStack() {
|
||||
return _buildArtworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtworkImage({
|
||||
required BoxFit fit,
|
||||
required Alignment alignment,
|
||||
int? memCacheWidth,
|
||||
}) {
|
||||
final placeholder = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
widget.gradientEndColor.withValues(alpha: 0.3),
|
||||
widget.gradientEndColor,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final imageStack = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(color: widget.gradientEndColor),
|
||||
SmartImage(
|
||||
url: widget.backdropUrl,
|
||||
fallbackUrls: widget.fallbackUrls,
|
||||
fit: fit,
|
||||
alignment: alignment,
|
||||
borderRadius: 0,
|
||||
memCacheWidth: memCacheWidth,
|
||||
placeholder: placeholder,
|
||||
onError: () {
|
||||
if (mounted && !_backdropFailed) {
|
||||
setState(() => _backdropFailed = true);
|
||||
}
|
||||
},
|
||||
resolveHttpHeaders: (url) =>
|
||||
widget.embyBaseUrl.isNotEmpty &&
|
||||
url.startsWith(widget.embyBaseUrl)
|
||||
? widget.imageHeaders
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
return imageStack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/widgets/app_dialog.dart';
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
|
||||
|
||||
class DetailHeroPanel extends StatelessWidget {
|
||||
final String title;
|
||||
final String? logoUrl;
|
||||
final String? posterUrl;
|
||||
|
||||
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
|
||||
final double? rating;
|
||||
final List<String> metaParts;
|
||||
final List<String> genres;
|
||||
final String? overview;
|
||||
final String? tagline;
|
||||
|
||||
|
||||
final Widget? actions;
|
||||
|
||||
const DetailHeroPanel({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.logoUrl,
|
||||
this.posterUrl,
|
||||
this.imageHeaders,
|
||||
this.rating,
|
||||
this.metaParts = const [],
|
||||
this.genres = const [],
|
||||
this.overview,
|
||||
this.tagline,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
bool get _hasOverview =>
|
||||
(overview?.trim().isNotEmpty ?? false) ||
|
||||
(tagline?.trim().isNotEmpty ?? false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
|
||||
|
||||
final titleBlock = _HeroTitle(
|
||||
title: title,
|
||||
logoUrl: logoUrl,
|
||||
posterUrl: posterUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
compact: compact,
|
||||
);
|
||||
final pills = _MetaPills(
|
||||
rating: rating,
|
||||
metaParts: metaParts,
|
||||
genres: genres,
|
||||
);
|
||||
|
||||
if (!compact && _hasOverview) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
titleBlock,
|
||||
if (actions != null) ...[const SizedBox(height: 24), actions!],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
pills,
|
||||
const SizedBox(height: 16),
|
||||
_Overview(overview: overview, tagline: tagline),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
titleBlock,
|
||||
const SizedBox(height: 12),
|
||||
pills,
|
||||
if (actions != null) ...[const SizedBox(height: 20), actions!],
|
||||
if (_hasOverview) ...[
|
||||
const SizedBox(height: 20),
|
||||
_Overview(overview: overview, tagline: tagline),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _HeroTitle extends StatefulWidget {
|
||||
final String title;
|
||||
final String? logoUrl;
|
||||
final String? posterUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final bool compact;
|
||||
|
||||
const _HeroTitle({
|
||||
required this.title,
|
||||
required this.logoUrl,
|
||||
required this.posterUrl,
|
||||
required this.imageHeaders,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_HeroTitle> createState() => _HeroTitleState();
|
||||
}
|
||||
|
||||
class _HeroTitleState extends State<_HeroTitle> {
|
||||
|
||||
|
||||
bool _logoFailed = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _HeroTitle oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.logoUrl != widget.logoUrl) _logoFailed = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final logo = widget.logoUrl;
|
||||
if (logo != null && logo.isNotEmpty && !_logoFailed) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.compact ? 240 : 400,
|
||||
maxHeight: widget.compact ? 80 : 120,
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: logo,
|
||||
httpHeaders: widget.imageHeaders,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomLeft,
|
||||
memCacheWidth: widget.compact ? 720 : 1200,
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
placeholder: (_, _) => _TitleText(title: widget.title),
|
||||
errorWidget: (_, _, _) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_logoFailed) {
|
||||
setState(() => _logoFailed = true);
|
||||
}
|
||||
});
|
||||
return _TitleText(title: widget.title);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.posterUrl != null && widget.posterUrl!.isNotEmpty) ...[
|
||||
SizedBox(
|
||||
width: widget.compact ? 104 : 130,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SmartImage(
|
||||
url: widget.posterUrl,
|
||||
httpHeaders: widget.imageHeaders,
|
||||
fallbackIcon: Icons.movie_outlined,
|
||||
borderRadius: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
_TitleText(title: widget.title),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleText extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _TitleText({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _MetaPills extends StatelessWidget {
|
||||
final double? rating;
|
||||
final List<String> metaParts;
|
||||
final List<String> genres;
|
||||
|
||||
const _MetaPills({
|
||||
required this.rating,
|
||||
required this.metaParts,
|
||||
required this.genres,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.appTokens;
|
||||
final r = rating;
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (r != null && r > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.ratingColor.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.star_rounded, size: 14, color: Colors.black87),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
r.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final part in [
|
||||
...metaParts,
|
||||
for (final g in genres.take(4))
|
||||
if (g.isNotEmpty) g,
|
||||
])
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Text(
|
||||
part,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _Overview extends StatelessWidget {
|
||||
final String? overview;
|
||||
final String? tagline;
|
||||
|
||||
const _Overview({required this.overview, required this.tagline});
|
||||
|
||||
void _showDialog(BuildContext context, String text) {
|
||||
showAppRawDialog<void>(
|
||||
context,
|
||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
||||
builder: (ctx) => ColoredBox(
|
||||
color: const Color(0xFF1a1c22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'简介',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.8,
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(
|
||||
'关闭',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = overview?.trim();
|
||||
final line = tagline?.trim();
|
||||
if ((body == null || body.isEmpty) && (line == null || line.isEmpty)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (line != null && line.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (body != null && body.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () => _showDialog(context, body),
|
||||
child: Text(
|
||||
body,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.7,
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'detail_backdrop.dart';
|
||||
|
||||
|
||||
class DetailImmersiveScaffold extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
final ValueNotifier<double> scrollProgress;
|
||||
final String? backdropUrl;
|
||||
final List<String> fallbackUrls;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final Widget child;
|
||||
final bool compact;
|
||||
final double horizontalPadding;
|
||||
|
||||
const DetailImmersiveScaffold({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.scrollProgress,
|
||||
required this.child,
|
||||
this.backdropUrl,
|
||||
this.fallbackUrls = const <String>[],
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
this.compact = false,
|
||||
this.horizontalPadding = 32,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final screenHeight = screenSize.height;
|
||||
final screenWidth = screenSize.width;
|
||||
final bannerHeight = compact
|
||||
? screenHeight * 0.30
|
||||
: (screenWidth * 9.0 / 16.0).clamp(280.0, screenHeight * 0.6);
|
||||
final backdrop = backdropUrl;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (backdrop != null)
|
||||
DetailBackdrop(
|
||||
scrollProgress: scrollProgress,
|
||||
backdropUrl: backdrop,
|
||||
fallbackUrls: fallbackUrls,
|
||||
embyBaseUrl: embyBaseUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
),
|
||||
SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: bannerHeight - 80),
|
||||
Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _FrostedPanelVeil()),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
horizontalPadding,
|
||||
64,
|
||||
horizontalPadding,
|
||||
48,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FrostedPanelVeil extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (Platform.isAndroid) {
|
||||
return IgnorePointer(
|
||||
child: ShaderMask(
|
||||
shaderCallback: (rect) {
|
||||
final ratio = (140.0 / rect.height).clamp(0.02, 0.5);
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: const [Colors.transparent, Colors.black],
|
||||
stops: [0.0, ratio],
|
||||
).createShader(rect);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0.0, 0.12, 1.0],
|
||||
colors: [
|
||||
const Color(0xFF0a0c10).withValues(alpha: 0.0),
|
||||
const Color(0xFF0a0c10).withValues(alpha: 0.88),
|
||||
const Color(0xFF0a0c10).withValues(alpha: 0.96),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
child: ShaderMask(
|
||||
shaderCallback: (rect) {
|
||||
final ratio = (140.0 / rect.height).clamp(0.02, 0.5);
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: const [Colors.transparent, Colors.black],
|
||||
stops: [0.0, ratio],
|
||||
).createShader(rect);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0.0, 0.20, 1.0],
|
||||
colors: [
|
||||
const Color(0xFF0a0c10).withValues(alpha: 0.0),
|
||||
const Color(0xFF0a0c10).withValues(alpha: 0.12),
|
||||
const Color(0xFF0a0c10).withValues(alpha: 0.20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/appearance_provider.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/widgets/top_drag_area.dart';
|
||||
import '../../../shared/widgets/window_chrome.dart';
|
||||
import '../../../shared/widgets/window_controls.dart';
|
||||
|
||||
const double _kDesktopTitleBarHeight = 38.0;
|
||||
|
||||
class DetailNavBar extends ConsumerWidget {
|
||||
final ValueListenable<double> scrollProgress;
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
final Color chromeColor;
|
||||
|
||||
const DetailNavBar({
|
||||
super.key,
|
||||
required this.scrollProgress,
|
||||
required this.title,
|
||||
required this.onBack,
|
||||
this.chromeColor = const Color(0xFF08090c),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDesktop = WindowChrome.isDesktop;
|
||||
final topPadding = isDesktop
|
||||
? _kDesktopTitleBarHeight
|
||||
: math.max(MediaQuery.paddingOf(context).top, _kDesktopTitleBarHeight);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
final headerMode =
|
||||
ref.watch(appearanceProvider).value?.headerMode ?? HeaderMode.frosted;
|
||||
final isFrosted = headerMode == HeaderMode.frosted;
|
||||
|
||||
final navHeight = topPadding + 30;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: navHeight,
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: scrollProgress,
|
||||
builder: (_, raw, _) {
|
||||
final p = raw.clamp(0.0, 1.0);
|
||||
if (isFrosted) {
|
||||
if (Platform.isAndroid && p < 0.05) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
final maxSigma = Platform.isAndroid ? 10.0 : 18.0;
|
||||
return ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: p * maxSigma,
|
||||
sigmaY: p * maxSigma,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: chromeColor.withValues(alpha: p * 0.7),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: const Color(
|
||||
0xFFFFFFFF,
|
||||
).withValues(alpha: p * 0.08),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ColoredBox(color: chromeColor.withValues(alpha: p));
|
||||
},
|
||||
),
|
||||
),
|
||||
const Positioned.fill(child: TopDragArea(child: SizedBox.expand())),
|
||||
Positioned(
|
||||
top: isDesktop
|
||||
? tokens.chromeInset
|
||||
: MediaQuery.paddingOf(context).top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: tokens.chromeInset),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: onBack,
|
||||
tooltip: '返回',
|
||||
constraints: const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
padding: const EdgeInsets.all(6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: scrollProgress,
|
||||
builder: (_, p, child) {
|
||||
return Opacity(opacity: p.clamp(0.0, 1.0), child: child);
|
||||
},
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
if (isDesktop) const WindowControls.forDarkSurface(),
|
||||
SizedBox(width: tokens.chromeInset),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
||||
double computeDetailScrollProgress(
|
||||
BuildContext context,
|
||||
double offset, {
|
||||
double? collapseExtent,
|
||||
}) {
|
||||
final mq = MediaQuery.of(context);
|
||||
if (mq.disableAnimations) return offset > 10 ? 1.0 : 0.0;
|
||||
final maxScroll = collapseExtent ?? mq.size.height * 0.8;
|
||||
final raw = (offset / maxScroll).clamp(0.0, 1.0);
|
||||
return (raw * 100).roundToDouble() / 100;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'detail_hero_panel.dart';
|
||||
import '../model/detail_view_state.dart';
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
|
||||
|
||||
class DetailViewRenderer extends StatelessWidget {
|
||||
final DetailViewState state;
|
||||
|
||||
const DetailViewRenderer({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hero = state.hero;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DetailHeroPanel(
|
||||
title: hero.title,
|
||||
logoUrl: hero.logoUrl,
|
||||
posterUrl: hero.posterUrl,
|
||||
imageHeaders: hero.imageHeaders,
|
||||
rating: hero.rating,
|
||||
metaParts: hero.metaParts,
|
||||
genres: hero.genres,
|
||||
overview: hero.overview,
|
||||
tagline: hero.tagline,
|
||||
actions: hero.actions,
|
||||
),
|
||||
for (final section in state.sections) section.child,
|
||||
if (state.showLoadingBelowHero)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../providers/emby_headers_provider.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../shared/mappers/media_image_url.dart';
|
||||
import '../../../shared/widgets/cast_scroller.dart';
|
||||
import 'section_container.dart';
|
||||
|
||||
class EmbyCastSection extends ConsumerWidget {
|
||||
final EmbyRawItem item;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const EmbyCastSection({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.embedded = false,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final people = _extractActors(item);
|
||||
if (people.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
|
||||
final headers = ref.watch(embyImageHeadersProvider).value;
|
||||
final hPad = embedded ? 0.0 : SectionContainer.hPadOf(context);
|
||||
|
||||
final entries = people.map((person) {
|
||||
final name = person['Name'] as String? ?? '';
|
||||
final role = person['Role'] as String?;
|
||||
final personId = person['Id'] as String?;
|
||||
final imageTag = person['PrimaryImageTag'] as String?;
|
||||
|
||||
final photoUrl = personId != null
|
||||
? _personImageUrl(
|
||||
session.serverUrl,
|
||||
session.token,
|
||||
personId,
|
||||
imageTag,
|
||||
)
|
||||
: null;
|
||||
final tappable = personId != null && personId.isNotEmpty;
|
||||
|
||||
return CastEntry(
|
||||
name: name,
|
||||
subtitle: role,
|
||||
imageUrl: photoUrl,
|
||||
imageHeaders: headers,
|
||||
onTap: tappable ? () => context.push('/person/$personId') : null,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return SectionContainer(
|
||||
title: '演职人员',
|
||||
embedded: embedded,
|
||||
leadingDivider: embedded,
|
||||
leading: leading,
|
||||
child: CastScroller(
|
||||
entries: entries,
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _extractActors(EmbyRawItem item) {
|
||||
final raw = item.extra['People'];
|
||||
if (raw is! List) return const [];
|
||||
return raw.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
static String? _personImageUrl(
|
||||
String baseUrl,
|
||||
String token,
|
||||
String personId,
|
||||
String? tag,
|
||||
) {
|
||||
if (tag == null) return null;
|
||||
return EmbyImageUrl.primaryById(
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: personId,
|
||||
maxHeight: 200,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../providers/di_providers.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../providers/tmdb_settings_provider.dart';
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/utils/emby_ticks.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import '../../../shared/utils/tmdb_episode_helpers.dart';
|
||||
import '../../../shared/widgets/adaptive_modal.dart';
|
||||
import '../../../shared/widgets/app_dialog.dart';
|
||||
import '../../../shared/widgets/app_inline_alert.dart';
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../../shared/widgets/card_progress_bar.dart';
|
||||
import '../../../shared/widgets/episode_thumb.dart';
|
||||
import '../../../shared/widgets/frosted_panel.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/season_selector.dart';
|
||||
import 'episode_selection_resolver.dart';
|
||||
|
||||
typedef EpisodeTapWithSeason =
|
||||
void Function(EmbyRawItem episode, int? seasonNumber);
|
||||
|
||||
class EpisodeListSection extends ConsumerStatefulWidget {
|
||||
final String seriesId;
|
||||
final String? tmdbSeriesId;
|
||||
final String? currentEpisodeId;
|
||||
|
||||
|
||||
final String? currentEpisodeSeasonId;
|
||||
|
||||
|
||||
final int? currentEpisodeSeasonNumber;
|
||||
final int? currentEpisodeIndexNumber;
|
||||
final String? initialSeasonId;
|
||||
final void Function(EmbyRawItem episode) onEpisodeTap;
|
||||
final EpisodeTapWithSeason? onEpisodeTapWithSeason;
|
||||
final void Function(EmbyRawItem episode)? onPlaybackPrefetch;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final String? serverId;
|
||||
|
||||
|
||||
final bool autoSelectFirstEpisode;
|
||||
|
||||
|
||||
final String? autoSelectEpisodeId;
|
||||
|
||||
|
||||
final String? autoSelectSeasonId;
|
||||
|
||||
|
||||
final Map<String, String>? showProviderIds;
|
||||
final String? showTitle;
|
||||
final int? showYear;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const EpisodeListSection({
|
||||
super.key,
|
||||
required this.seriesId,
|
||||
this.tmdbSeriesId,
|
||||
this.currentEpisodeId,
|
||||
this.currentEpisodeSeasonId,
|
||||
this.currentEpisodeSeasonNumber,
|
||||
this.currentEpisodeIndexNumber,
|
||||
this.initialSeasonId,
|
||||
required this.onEpisodeTap,
|
||||
this.onEpisodeTapWithSeason,
|
||||
this.onPlaybackPrefetch,
|
||||
this.embedded = false,
|
||||
this.serverId,
|
||||
this.autoSelectFirstEpisode = true,
|
||||
this.autoSelectEpisodeId,
|
||||
this.autoSelectSeasonId,
|
||||
this.showProviderIds,
|
||||
this.showTitle,
|
||||
this.showYear,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<EpisodeListSection> createState() => _EpisodeListSectionState();
|
||||
}
|
||||
|
||||
class _EpisodeListSectionState extends ConsumerState<EpisodeListSection> {
|
||||
String? _selectedSeasonId;
|
||||
|
||||
void _selectSeason(String seasonId) {
|
||||
if (_selectedSeasonId == seasonId) return;
|
||||
setState(() => _selectedSeasonId = seasonId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scopedServerId = widget.serverId;
|
||||
late final String targetServerId;
|
||||
late final String baseUrl;
|
||||
late final String token;
|
||||
|
||||
if (scopedServerId != null) {
|
||||
final authAsync = ref.watch(tmdbServerScopedAuthProvider(scopedServerId));
|
||||
final auth = authAsync.value;
|
||||
if (auth == null) {
|
||||
return authAsync.isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
targetServerId = scopedServerId;
|
||||
baseUrl = auth.baseUrl;
|
||||
token = auth.token;
|
||||
} else {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
targetServerId = session.serverId;
|
||||
baseUrl = session.serverUrl;
|
||||
token = session.token;
|
||||
}
|
||||
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final compact = width < Breakpoints.detail;
|
||||
final hPad = compact ? 16.0 : 32.0;
|
||||
|
||||
final seasonsAsync = ref.watch(
|
||||
seriesSeasonsDataProvider((
|
||||
serverId: targetServerId,
|
||||
seriesId: widget.seriesId,
|
||||
)),
|
||||
);
|
||||
|
||||
return seasonsAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
error: (error, stackTrace) => const SizedBox.shrink(),
|
||||
data: (seasons) {
|
||||
if (seasons.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final String? effectiveSeasonId =
|
||||
_selectedSeasonId ??
|
||||
resolveInitialSeasonId(
|
||||
seasons: seasons,
|
||||
autoSelectSeasonId: widget.autoSelectSeasonId,
|
||||
initialSeasonId: widget.initialSeasonId,
|
||||
currentEpisodeSeasonId: widget.currentEpisodeSeasonId,
|
||||
currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber,
|
||||
);
|
||||
EmbyRawSeason? selectedSeason;
|
||||
for (final season in seasons) {
|
||||
if (season.Id == effectiveSeasonId) {
|
||||
selectedSeason = season;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.embedded && widget.leading != null) widget.leading!,
|
||||
const SizedBox(height: 16),
|
||||
if (effectiveSeasonId != null)
|
||||
_EpisodesBody(
|
||||
seasonTabsWidget: seasons.length > 1
|
||||
? SeasonSelector(
|
||||
seasons: seasons,
|
||||
selectedId: effectiveSeasonId,
|
||||
onSelect: _selectSeason,
|
||||
)
|
||||
: null,
|
||||
seriesId: widget.seriesId,
|
||||
seasonId: effectiveSeasonId,
|
||||
seasonNumber: selectedSeason == null
|
||||
? null
|
||||
: seasonNumberFrom(selectedSeason),
|
||||
tmdbSeriesId: widget.tmdbSeriesId,
|
||||
currentEpisodeId: widget.currentEpisodeId,
|
||||
currentEpisodeSeasonId: widget.currentEpisodeSeasonId,
|
||||
currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber,
|
||||
currentEpisodeIndexNumber: widget.currentEpisodeIndexNumber,
|
||||
serverId: targetServerId,
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
autoSelectFirstEpisode: widget.autoSelectFirstEpisode,
|
||||
autoSelectEpisodeId: widget.autoSelectEpisodeId,
|
||||
onEpisodeTap: widget.onEpisodeTap,
|
||||
onEpisodeTapWithSeason: widget.onEpisodeTapWithSeason,
|
||||
onPlaybackPrefetch: widget.onPlaybackPrefetch,
|
||||
showProviderIds: widget.showProviderIds,
|
||||
showTitle: widget.showTitle,
|
||||
showYear: widget.showYear,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (widget.embedded) return content;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
child: FrostedPanel(
|
||||
radius: 16,
|
||||
padding: EdgeInsets.all(hPad),
|
||||
blurSigma: 15,
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EpisodesBody extends ConsumerStatefulWidget {
|
||||
final String seriesId;
|
||||
final String seasonId;
|
||||
final int? seasonNumber;
|
||||
final String? tmdbSeriesId;
|
||||
final String? currentEpisodeId;
|
||||
final String? currentEpisodeSeasonId;
|
||||
final int? currentEpisodeSeasonNumber;
|
||||
final int? currentEpisodeIndexNumber;
|
||||
final String serverId;
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
final bool autoSelectFirstEpisode;
|
||||
final String? autoSelectEpisodeId;
|
||||
final void Function(EmbyRawItem episode) onEpisodeTap;
|
||||
final EpisodeTapWithSeason? onEpisodeTapWithSeason;
|
||||
final void Function(EmbyRawItem episode)? onPlaybackPrefetch;
|
||||
final Map<String, String>? showProviderIds;
|
||||
final String? showTitle;
|
||||
final int? showYear;
|
||||
final Widget? seasonTabsWidget;
|
||||
|
||||
const _EpisodesBody({
|
||||
required this.seriesId,
|
||||
required this.seasonId,
|
||||
this.seasonNumber,
|
||||
this.tmdbSeriesId,
|
||||
required this.currentEpisodeId,
|
||||
this.currentEpisodeSeasonId,
|
||||
this.currentEpisodeSeasonNumber,
|
||||
this.currentEpisodeIndexNumber,
|
||||
required this.serverId,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
required this.autoSelectFirstEpisode,
|
||||
this.autoSelectEpisodeId,
|
||||
required this.onEpisodeTap,
|
||||
this.onEpisodeTapWithSeason,
|
||||
this.onPlaybackPrefetch,
|
||||
this.showProviderIds,
|
||||
this.showTitle,
|
||||
this.showYear,
|
||||
this.seasonTabsWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_EpisodesBody> createState() => _EpisodesBodyState();
|
||||
}
|
||||
|
||||
class _EpisodesBodyState extends ConsumerState<_EpisodesBody> {
|
||||
static final double _itemWidth = Platform.isAndroid ? 180.0 : 260.0;
|
||||
static const _itemGap = 12.0;
|
||||
static final double _rowHeight = Platform.isAndroid ? 150.0 : 210.0;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _hasScrolledToActive = false;
|
||||
bool _hasAutoSelected = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _EpisodesBody oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.seasonId != widget.seasonId ||
|
||||
oldWidget.currentEpisodeId != widget.currentEpisodeId) {
|
||||
_hasScrolledToActive = false;
|
||||
}
|
||||
if (oldWidget.seasonId != widget.seasonId ||
|
||||
oldWidget.autoSelectEpisodeId != widget.autoSelectEpisodeId) {
|
||||
_hasAutoSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
String? _resolveActiveEpisodeId(List<EmbyRawItem> episodes) =>
|
||||
resolveActiveEpisodeId(
|
||||
episodes: episodes,
|
||||
currentEpisodeId: widget.currentEpisodeId,
|
||||
displayedSeasonId: widget.seasonId,
|
||||
displayedSeasonNumber: widget.seasonNumber,
|
||||
currentEpisodeSeasonId: widget.currentEpisodeSeasonId,
|
||||
currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber,
|
||||
currentEpisodeIndexNumber: widget.currentEpisodeIndexNumber,
|
||||
);
|
||||
|
||||
void _scrollToActive(List<EmbyRawItem> episodes, String? activeEpisodeId) {
|
||||
if (_hasScrolledToActive) return;
|
||||
if (activeEpisodeId == null) return;
|
||||
final idx = episodes.indexWhere((e) => e.Id == activeEpisodeId);
|
||||
if (idx < 0) return;
|
||||
_hasScrolledToActive = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
final position = _scrollController.position;
|
||||
final itemExtent = _itemWidth + _itemGap;
|
||||
final viewport = position.viewportDimension;
|
||||
final target = idx * itemExtent - (viewport - _itemWidth) / 2;
|
||||
_scrollController.jumpTo(target.clamp(0.0, position.maxScrollExtent));
|
||||
});
|
||||
}
|
||||
|
||||
EmbyRawItem? _prefetchTarget(
|
||||
List<EmbyRawItem> episodes,
|
||||
String? activeEpisodeId,
|
||||
) {
|
||||
if (episodes.isEmpty) return null;
|
||||
if (activeEpisodeId != null && activeEpisodeId.isNotEmpty) {
|
||||
for (final episode in episodes) {
|
||||
if (episode.Id == activeEpisodeId) return episode;
|
||||
}
|
||||
}
|
||||
return episodes.first;
|
||||
}
|
||||
|
||||
void _handleEpisodeTap(EmbyRawItem episode) {
|
||||
final tapWithSeason = widget.onEpisodeTapWithSeason;
|
||||
if (tapWithSeason != null) {
|
||||
tapWithSeason(episode, widget.seasonNumber);
|
||||
return;
|
||||
}
|
||||
widget.onEpisodeTap(episode);
|
||||
}
|
||||
|
||||
void _showAllEpisodes(
|
||||
List<EmbyRawItem> episodes,
|
||||
String? activeEpisodeId,
|
||||
Map<int, String> tmdbStillUrls,
|
||||
) {
|
||||
showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: 720,
|
||||
builder: (sheetContext) => _EpisodeGridSheet(
|
||||
episodes: episodes,
|
||||
seriesId: widget.seriesId,
|
||||
activeEpisodeId: activeEpisodeId,
|
||||
tmdbStillUrls: tmdbStillUrls,
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
onEpisodeTap: (ep) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_handleEpisodeTap(ep);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tmdbSettings = ref.watch(tmdbSettingsProvider).value;
|
||||
final canUseTmdbSeason =
|
||||
(tmdbSettings?.canRequest ?? false) &&
|
||||
widget.tmdbSeriesId != null &&
|
||||
widget.tmdbSeriesId!.isNotEmpty &&
|
||||
widget.seasonNumber != null;
|
||||
final tmdbSeason = canUseTmdbSeason
|
||||
? ref
|
||||
.watch(
|
||||
tmdbSeasonDetailProvider((
|
||||
tmdbId: widget.tmdbSeriesId!,
|
||||
seasonNumber: widget.seasonNumber!,
|
||||
)),
|
||||
)
|
||||
.value
|
||||
: null;
|
||||
final tmdbStillUrls = tmdbStillUrlsByEpisode(
|
||||
tmdbSeason,
|
||||
tmdbSettings?.effectiveImageBaseUrl,
|
||||
);
|
||||
final episodesAsync = ref.watch(
|
||||
seriesEpisodesDataProvider((
|
||||
serverId: widget.serverId,
|
||||
seriesId: widget.seriesId,
|
||||
seasonId: widget.seasonId,
|
||||
)),
|
||||
);
|
||||
|
||||
return episodesAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
error: (error, stackTrace) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: AppInlineAlert(message: '剧集列表加载失败'),
|
||||
),
|
||||
data: (episodes) {
|
||||
if (episodes.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'暂无分集',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.currentEpisodeId == null &&
|
||||
!_hasAutoSelected &&
|
||||
episodes.isNotEmpty) {
|
||||
final targetId = widget.autoSelectEpisodeId;
|
||||
EmbyRawItem? target;
|
||||
if (targetId != null && targetId.isNotEmpty) {
|
||||
for (final e in episodes) {
|
||||
if (e.Id == targetId) {
|
||||
target = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
final pick =
|
||||
target ?? (widget.autoSelectFirstEpisode ? episodes.first : null);
|
||||
if (pick != null) {
|
||||
_hasAutoSelected = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _handleEpisodeTap(pick);
|
||||
});
|
||||
}
|
||||
}
|
||||
final activeEpisodeId = _resolveActiveEpisodeId(episodes);
|
||||
final target = _prefetchTarget(episodes, activeEpisodeId);
|
||||
if (target != null) widget.onPlaybackPrefetch?.call(target);
|
||||
_scrollToActive(episodes, activeEpisodeId);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (widget.seasonTabsWidget != null)
|
||||
Expanded(child: widget.seasonTabsWidget!)
|
||||
else
|
||||
const Spacer(),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: () => _showAllEpisodes(
|
||||
episodes,
|
||||
activeEpisodeId,
|
||||
tmdbStillUrls,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white.withValues(alpha: 0.6),
|
||||
minimumSize: Size.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('查看全部', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: _rowHeight,
|
||||
child: HoverScrollableRow(
|
||||
controller: _scrollController,
|
||||
buttonInset: 4,
|
||||
scrollFraction: 0.5,
|
||||
builder: (_, controller) => ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (_, i) {
|
||||
final ep = episodes[i];
|
||||
final tmdbStillUrl = ep.IndexNumber == null
|
||||
? null
|
||||
: tmdbStillUrls[ep.IndexNumber];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i < episodes.length - 1 ? _itemGap : 0,
|
||||
),
|
||||
child: _EpisodeCard(
|
||||
episode: ep,
|
||||
seriesId: widget.seriesId,
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
tmdbStillUrl: tmdbStillUrl,
|
||||
isActive: ep.Id == activeEpisodeId,
|
||||
onTap: () => _handleEpisodeTap(ep),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EpisodeCard extends StatefulWidget {
|
||||
final EmbyRawItem episode;
|
||||
final String seriesId;
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
final String? tmdbStillUrl;
|
||||
final bool isActive;
|
||||
final VoidCallback onTap;
|
||||
|
||||
|
||||
final double? width;
|
||||
final int overviewMaxLines;
|
||||
|
||||
const _EpisodeCard({
|
||||
required this.episode,
|
||||
required this.seriesId,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
required this.tmdbStillUrl,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
this.width,
|
||||
this.overviewMaxLines = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EpisodeCard> createState() => _EpisodeCardState();
|
||||
}
|
||||
|
||||
class _EpisodeCardState extends State<_EpisodeCard> {
|
||||
bool _hovered = false;
|
||||
|
||||
void _showEpisodeOverviewDialog(BuildContext context, String overview) {
|
||||
showAppRawDialog<void>(
|
||||
context,
|
||||
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 400),
|
||||
builder: (ctx) => ColoredBox(
|
||||
color: const Color(0xFF1a1c22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.episode.IndexNumber != null
|
||||
? '${formatEpisodeNumber(widget.episode.IndexNumber)} ${widget.episode.Name}'
|
||||
: widget.episode.Name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
overview,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.75,
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(
|
||||
'关闭',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ep = widget.episode;
|
||||
final progress = playbackProgress(widget.episode);
|
||||
final epNum = formatEpisodeNumber(ep.IndexNumber);
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: SizedBox(
|
||||
width: widget.width ?? _EpisodesBodyState._itemWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
EpisodeThumb(
|
||||
episode: widget.episode,
|
||||
seriesId: widget.seriesId,
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
tmdbStillUrl: widget.tmdbStillUrl,
|
||||
aspectRatio: 16 / 9,
|
||||
),
|
||||
|
||||
if (_hovered)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (progress > 0) CardProgressBar(progress: progress),
|
||||
|
||||
if (epNum != null)
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
epNum,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.isActive)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
ep.IndexNumber != null
|
||||
? '${formatEpisodeNumber(ep.IndexNumber)} ${ep.Name}'
|
||||
: ep.Name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: widget.isActive
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
if (ep.Overview != null && ep.Overview!.isNotEmpty)
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
_showEpisodeOverviewDialog(context, ep.Overview!),
|
||||
child: Text(
|
||||
ep.Overview!,
|
||||
maxLines: widget.overviewMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _EpisodeGridSheet extends StatefulWidget {
|
||||
final List<EmbyRawItem> episodes;
|
||||
final String seriesId;
|
||||
final String? activeEpisodeId;
|
||||
final Map<int, String> tmdbStillUrls;
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
final void Function(EmbyRawItem episode) onEpisodeTap;
|
||||
|
||||
const _EpisodeGridSheet({
|
||||
required this.episodes,
|
||||
required this.seriesId,
|
||||
required this.activeEpisodeId,
|
||||
required this.tmdbStillUrls,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
required this.onEpisodeTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EpisodeGridSheet> createState() => _EpisodeGridSheetState();
|
||||
}
|
||||
|
||||
class _EpisodeGridSheetState extends State<_EpisodeGridSheet> {
|
||||
static const _maxCrossAxisExtent = 240.0;
|
||||
static const _gridSpacing = 12.0;
|
||||
|
||||
static const _childAspectRatio = 1.05;
|
||||
static const _hPad = 20.0;
|
||||
|
||||
ScrollController? _controller;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
ScrollController _controllerFor(double gridWidth) {
|
||||
final existing = _controller;
|
||||
if (existing != null) return existing;
|
||||
|
||||
double offset = 0;
|
||||
final idx = widget.episodes.indexWhere(
|
||||
(e) => e.Id == widget.activeEpisodeId,
|
||||
);
|
||||
if (idx > 0) {
|
||||
|
||||
final columns = (gridWidth / (_maxCrossAxisExtent + _gridSpacing))
|
||||
.ceil()
|
||||
.clamp(1, 100);
|
||||
final itemWidth = (gridWidth - (columns - 1) * _gridSpacing) / columns;
|
||||
final rowHeight = itemWidth / _childAspectRatio + _gridSpacing;
|
||||
offset = (idx ~/ columns) * rowHeight;
|
||||
}
|
||||
return _controller = ScrollController(initialScrollOffset: offset);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return ColoredBox(
|
||||
color: const Color(0xFF1a1c22),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(_hPad, 16, _hPad, 12),
|
||||
child: Text(
|
||||
'${widget.episodes.length} 集',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final gridWidth = constraints.maxWidth - _hPad * 2;
|
||||
return GridView.builder(
|
||||
controller: _controllerFor(gridWidth),
|
||||
padding: const EdgeInsets.fromLTRB(_hPad, 0, _hPad, _hPad),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _maxCrossAxisExtent,
|
||||
mainAxisSpacing: _gridSpacing,
|
||||
crossAxisSpacing: _gridSpacing,
|
||||
childAspectRatio: _childAspectRatio,
|
||||
),
|
||||
itemCount: widget.episodes.length,
|
||||
itemBuilder: (_, i) {
|
||||
final ep = widget.episodes[i];
|
||||
final tmdbStillUrl = ep.IndexNumber == null
|
||||
? null
|
||||
: widget.tmdbStillUrls[ep.IndexNumber];
|
||||
return _EpisodeCard(
|
||||
episode: ep,
|
||||
seriesId: widget.seriesId,
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
tmdbStillUrl: tmdbStillUrl,
|
||||
isActive: ep.Id == widget.activeEpisodeId,
|
||||
width: double.infinity,
|
||||
overviewMaxLines: 2,
|
||||
onTap: () => widget.onEpisodeTap(ep),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/utils/tmdb_episode_helpers.dart';
|
||||
|
||||
|
||||
String? resolveInitialSeasonId({
|
||||
required List<EmbyRawSeason> seasons,
|
||||
String? autoSelectSeasonId,
|
||||
String? initialSeasonId,
|
||||
String? currentEpisodeSeasonId,
|
||||
int? currentEpisodeSeasonNumber,
|
||||
}) {
|
||||
if (seasons.isEmpty) return null;
|
||||
bool has(String? id) => id != null && seasons.any((s) => s.Id == id);
|
||||
if (has(autoSelectSeasonId)) return autoSelectSeasonId;
|
||||
if (has(initialSeasonId)) return initialSeasonId;
|
||||
if (has(currentEpisodeSeasonId)) return currentEpisodeSeasonId;
|
||||
if (currentEpisodeSeasonNumber != null) {
|
||||
for (final s in seasons) {
|
||||
if (seasonNumberFrom(s) == currentEpisodeSeasonNumber) return s.Id;
|
||||
}
|
||||
}
|
||||
return seasons.first.Id;
|
||||
}
|
||||
|
||||
|
||||
String? resolveActiveEpisodeId({
|
||||
required List<EmbyRawItem> episodes,
|
||||
String? currentEpisodeId,
|
||||
String? displayedSeasonId,
|
||||
int? displayedSeasonNumber,
|
||||
String? currentEpisodeSeasonId,
|
||||
int? currentEpisodeSeasonNumber,
|
||||
int? currentEpisodeIndexNumber,
|
||||
}) {
|
||||
final id = currentEpisodeId;
|
||||
if (id == null || id.isEmpty) return null;
|
||||
if (episodes.any((e) => e.Id == id)) return id;
|
||||
final seasonMatches =
|
||||
(currentEpisodeSeasonId != null &&
|
||||
currentEpisodeSeasonId == displayedSeasonId) ||
|
||||
(currentEpisodeSeasonNumber != null &&
|
||||
displayedSeasonNumber != null &&
|
||||
currentEpisodeSeasonNumber == displayedSeasonNumber);
|
||||
if (seasonMatches && currentEpisodeIndexNumber != null) {
|
||||
for (final e in episodes) {
|
||||
if (e.IndexNumber == currentEpisodeIndexNumber) return e.Id;
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../../shared/widgets/auto_dismiss_menu.dart';
|
||||
|
||||
|
||||
class HeroIconActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final double height;
|
||||
final VoidCallback onPressed;
|
||||
final String? label;
|
||||
|
||||
const HeroIconActionButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.height,
|
||||
required this.onPressed,
|
||||
this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (label != null) {
|
||||
return GestureDetector(
|
||||
onTap: onPressed,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 24, color: Colors.white),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.4)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: Icon(icon, size: height * 0.44),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final _heroPillBorder = StadiumBorder(
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.55)),
|
||||
);
|
||||
|
||||
const _heroDropdownMenuMaxHeight = 320.0;
|
||||
|
||||
class HeroPillDropdownButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final List<FItem> menuChildren;
|
||||
final double height;
|
||||
|
||||
const HeroPillDropdownButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.menuChildren,
|
||||
this.height = 52,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FPopoverMenu(
|
||||
menuBuilder: autoDismissMenuBuilder,
|
||||
menuAnchor: Alignment.topCenter,
|
||||
childAnchor: Alignment.bottomCenter,
|
||||
maxHeight: _heroDropdownMenuMaxHeight,
|
||||
menu: [FItemGroup(children: menuChildren)],
|
||||
builder: (context, controller, child) => GestureDetector(
|
||||
onTap: controller.toggle,
|
||||
child: child,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: _heroPillBorder,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Icon(icon, size: 22, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DetailHeroActions extends StatelessWidget {
|
||||
|
||||
final bool showReplay;
|
||||
|
||||
|
||||
final VoidCallback? onReplay;
|
||||
|
||||
final bool isPlayed;
|
||||
final VoidCallback onTogglePlayed;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onToggleFavorite;
|
||||
|
||||
|
||||
final List<Widget> extraIconActions;
|
||||
|
||||
|
||||
final VoidCallback? onPlay;
|
||||
|
||||
|
||||
final String playLabel;
|
||||
|
||||
|
||||
final double? progressPercent;
|
||||
|
||||
|
||||
final String? trailingLabel;
|
||||
|
||||
final bool isPlayLoading;
|
||||
final bool compact;
|
||||
|
||||
const DetailHeroActions({
|
||||
super.key,
|
||||
this.showReplay = false,
|
||||
this.onReplay,
|
||||
required this.isPlayed,
|
||||
required this.onTogglePlayed,
|
||||
required this.isFavorite,
|
||||
required this.onToggleFavorite,
|
||||
this.extraIconActions = const [],
|
||||
this.onPlay,
|
||||
this.playLabel = '播放',
|
||||
this.progressPercent,
|
||||
this.trailingLabel,
|
||||
this.isPlayLoading = false,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btnH = compact ? 44.0 : 52.0;
|
||||
|
||||
final iconRow = Row(
|
||||
children: [
|
||||
if (showReplay) ...[
|
||||
Expanded(
|
||||
child: HeroIconActionButton(
|
||||
icon: Icons.replay,
|
||||
height: btnH,
|
||||
label: compact ? '重播' : null,
|
||||
onPressed: onReplay ?? () {},
|
||||
),
|
||||
),
|
||||
SizedBox(width: compact ? 0 : 10),
|
||||
],
|
||||
Expanded(
|
||||
child: HeroIconActionButton(
|
||||
icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline,
|
||||
height: btnH,
|
||||
label: compact ? '已看' : null,
|
||||
onPressed: onTogglePlayed,
|
||||
),
|
||||
),
|
||||
SizedBox(width: compact ? 0 : 10),
|
||||
Expanded(
|
||||
child: HeroIconActionButton(
|
||||
icon: isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
height: btnH,
|
||||
label: compact ? '收藏' : null,
|
||||
onPressed: onToggleFavorite,
|
||||
),
|
||||
),
|
||||
for (final extra in extraIconActions) ...[
|
||||
SizedBox(width: compact ? 0 : 10),
|
||||
Expanded(child: extra),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
final playButton = onPlay != null
|
||||
? HeroPlayButton(
|
||||
height: btnH,
|
||||
label: playLabel,
|
||||
progressPercent: progressPercent,
|
||||
trailingLabel: trailingLabel,
|
||||
isLoading: isPlayLoading,
|
||||
compact: compact,
|
||||
onPressed: onPlay,
|
||||
)
|
||||
: null;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: compact ? double.infinity : 360),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: compact
|
||||
? [
|
||||
if (playButton != null) ...[
|
||||
playButton,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
iconRow,
|
||||
]
|
||||
: [
|
||||
iconRow,
|
||||
if (playButton != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
playButton,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class HeroPlayButton extends StatelessWidget {
|
||||
final double height;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final double? progressPercent;
|
||||
final String? trailingLabel;
|
||||
final bool isLoading;
|
||||
final String loadingLabel;
|
||||
final bool compact;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const HeroPlayButton({
|
||||
super.key,
|
||||
required this.height,
|
||||
required this.label,
|
||||
this.icon = Icons.play_arrow,
|
||||
this.progressPercent,
|
||||
this.trailingLabel,
|
||||
this.isLoading = false,
|
||||
this.loadingLabel = '启动中',
|
||||
this.compact = false,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(12);
|
||||
final hasProgress = progressPercent != null && progressPercent! > 0;
|
||||
|
||||
final buttonChild = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
isLoading
|
||||
? const AppLoadingRing(size: 18, color: Colors.black54)
|
||||
: Icon(icon, size: compact ? 22 : 26),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isLoading ? loadingLabel : label,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 15 : 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (trailingLabel != null && !isLoading) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
trailingLabel!,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 13 : 14,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (hasProgress) {
|
||||
final fraction = (progressPercent! / 100).clamp(0.0, 1.0);
|
||||
return ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: const [Color(0xFFE3E3E3), Colors.white],
|
||||
stops: [fraction, fraction],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: FilledButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.black,
|
||||
overlayColor: Colors.black.withValues(alpha: 0.08),
|
||||
shadowColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: radius),
|
||||
),
|
||||
child: buttonChild,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: FilledButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: radius),
|
||||
),
|
||||
child: buttonChild,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/widgets/frosted_panel.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
|
||||
class MediaInfoSection extends StatelessWidget {
|
||||
final EmbyRawItem item;
|
||||
final int selectedSourceIndex;
|
||||
final EmbyRawMediaSource? selectedSource;
|
||||
final bool embedded;
|
||||
|
||||
const MediaInfoSection({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.selectedSourceIndex = 0,
|
||||
this.selectedSource,
|
||||
this.embedded = false,
|
||||
});
|
||||
|
||||
static bool hasVisibleContent({
|
||||
required EmbyRawItem item,
|
||||
int selectedSourceIndex = 0,
|
||||
EmbyRawMediaSource? selectedSource,
|
||||
}) {
|
||||
if (item.Type == 'Series') return false;
|
||||
|
||||
final EmbyRawMediaSource source;
|
||||
if (selectedSource != null) {
|
||||
source = selectedSource;
|
||||
} else {
|
||||
final sources = mediaSourcesOfItem(item);
|
||||
if (sources.isEmpty) return false;
|
||||
source = sources[selectedSourceIndex.clamp(0, sources.length - 1)];
|
||||
}
|
||||
final streams = source.MediaStreams ?? [];
|
||||
if (streams.isEmpty) return false;
|
||||
|
||||
return streams.any(
|
||||
(stream) =>
|
||||
stream.Type == 'Video' ||
|
||||
stream.Type == 'Audio' ||
|
||||
stream.Type == 'Subtitle',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!hasVisibleContent(
|
||||
item: item,
|
||||
selectedSourceIndex: selectedSourceIndex,
|
||||
selectedSource: selectedSource,
|
||||
)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final EmbyRawMediaSource source;
|
||||
if (selectedSource != null) {
|
||||
source = selectedSource!;
|
||||
} else {
|
||||
final sources = mediaSourcesOfItem(item);
|
||||
if (sources.isEmpty) return const SizedBox.shrink();
|
||||
source = sources[selectedSourceIndex.clamp(0, sources.length - 1)];
|
||||
}
|
||||
final streams = source.MediaStreams ?? [];
|
||||
if (streams.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final hPad = embedded
|
||||
? 0.0
|
||||
: (MediaQuery.sizeOf(context).width < Breakpoints.detail ? 16.0 : 32.0);
|
||||
|
||||
final defaultAudioIdx = source.DefaultAudioStreamIndex;
|
||||
final cards = <Widget>[];
|
||||
|
||||
for (final s in streams.where((s) => s.Type == 'Video')) {
|
||||
cards.add(_StreamCard(stream: s, kind: _Kind.video));
|
||||
}
|
||||
for (final s in streams.where((s) => s.Type == 'Audio')) {
|
||||
final isDefault =
|
||||
(s.Index != null && s.Index == defaultAudioIdx) ||
|
||||
(s.IsDefault ?? false);
|
||||
cards.add(
|
||||
_StreamCard(stream: s, kind: _Kind.audio, isDefault: isDefault),
|
||||
);
|
||||
}
|
||||
for (final s in streams.where((s) => s.Type == 'Subtitle')) {
|
||||
cards.add(
|
||||
_StreamCard(
|
||||
stream: s,
|
||||
kind: _Kind.subtitle,
|
||||
isDefault: s.IsDefault ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (cards.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const FDivider(style: .delta(color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.symmetric(vertical: 24)))),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16),
|
||||
child: Text(
|
||||
'媒体信息',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 280,
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, controller) => ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
itemCount: cards.length,
|
||||
itemBuilder: (_, i) => Padding(
|
||||
padding: EdgeInsets.only(right: i < cards.length - 1 ? 12 : 0),
|
||||
child: cards[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Kind { video, audio, subtitle }
|
||||
|
||||
class _StreamCard extends StatelessWidget {
|
||||
final EmbyRawMediaStream stream;
|
||||
final _Kind kind;
|
||||
final bool isDefault;
|
||||
|
||||
const _StreamCard({
|
||||
required this.stream,
|
||||
required this.kind,
|
||||
this.isDefault = false,
|
||||
});
|
||||
|
||||
List<(String label, String value)> _fields() {
|
||||
final ex = stream.extra;
|
||||
final rows = <(String, String)>[];
|
||||
|
||||
switch (kind) {
|
||||
case _Kind.video:
|
||||
_add(rows, '编码', stream.Codec);
|
||||
final w = ex['Width'] as int?;
|
||||
final h = ex['Height'] as int?;
|
||||
if (w != null && h != null) rows.add(('分辨率', '${w}x$h'));
|
||||
final fr =
|
||||
(ex['RealFrameRate'] as num?) ?? (ex['AverageFrameRate'] as num?);
|
||||
if (fr != null) rows.add(('帧率', fr.toDouble().toStringAsFixed(1)));
|
||||
final br = ex['BitRate'] as int?;
|
||||
if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps'));
|
||||
_add(rows, '动态范围', ex['VideoRange'] as String?);
|
||||
_add(rows, '配置', ex['Profile'] as String?);
|
||||
case _Kind.audio:
|
||||
_add(rows, '标题', stream.Title);
|
||||
_add(rows, '内嵌标题', stream.DisplayTitle);
|
||||
_add(rows, '语言', stream.Language);
|
||||
_add(rows, '布局', ex['ChannelLayout'] as String?);
|
||||
if (stream.Channels != null) rows.add(('声道', '${stream.Channels}'));
|
||||
_add(rows, '编码', stream.Codec);
|
||||
final br = ex['BitRate'] as int?;
|
||||
if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps'));
|
||||
final sr = ex['SampleRate'] as int?;
|
||||
if (sr != null) rows.add(('采样率', '${sr}Hz'));
|
||||
rows.add(('外部', (stream.IsExternal ?? false) ? '是' : '否'));
|
||||
rows.add(('默认', (stream.IsDefault ?? false) ? '是' : '否'));
|
||||
case _Kind.subtitle:
|
||||
_add(rows, '标题', stream.Title);
|
||||
_add(rows, '内嵌标题', stream.DisplayTitle);
|
||||
_add(rows, '语言', stream.Language);
|
||||
_add(rows, '格式', stream.Codec);
|
||||
rows.add(('外部', (stream.IsExternal ?? false) ? '是' : '否'));
|
||||
if (stream.IsForced ?? false) rows.add(('强制', '是'));
|
||||
rows.add(('默认', (stream.IsDefault ?? false) ? '是' : '否'));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
static void _add(List<(String, String)> rows, String label, String? value) {
|
||||
if (value != null && value.isNotEmpty) rows.add((label, value));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fields = _fields();
|
||||
|
||||
final (IconData icon, String name) = switch (kind) {
|
||||
_Kind.video => (Icons.videocam_outlined, '视频'),
|
||||
_Kind.audio => (Icons.music_note_outlined, '音频'),
|
||||
_Kind.subtitle => (Icons.subtitles_outlined, '字幕'),
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
width: 260,
|
||||
height: 280,
|
||||
child: FrostedPanel(
|
||||
radius: 12,
|
||||
padding: const EdgeInsets.all(16),
|
||||
blurSigma: 15,
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
color: Colors.black26,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: Colors.white),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (isDefault) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (label, value) in fields)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 5),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
|
||||
library;
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../providers/version_priority_provider.dart';
|
||||
import '../../../shared/widgets/version_filter.dart';
|
||||
import '../../../shared/widgets/version_source_card.dart';
|
||||
|
||||
|
||||
class MultiSourceVersionEntry {
|
||||
final String bucket;
|
||||
final VersionSourceCardData data;
|
||||
final EmbyRawMediaSource source;
|
||||
final bool isLocal;
|
||||
|
||||
|
||||
final bool isActive;
|
||||
|
||||
|
||||
final int localIndex;
|
||||
|
||||
final String serverName;
|
||||
final String serverId;
|
||||
final String itemId;
|
||||
final String mediaSourceId;
|
||||
|
||||
const MultiSourceVersionEntry({
|
||||
required this.bucket,
|
||||
required this.data,
|
||||
required this.source,
|
||||
required this.isLocal,
|
||||
required this.isActive,
|
||||
required this.localIndex,
|
||||
required this.serverName,
|
||||
required this.serverId,
|
||||
required this.itemId,
|
||||
required this.mediaSourceId,
|
||||
});
|
||||
}
|
||||
|
||||
void sortMultiSourceEntries(
|
||||
List<MultiSourceVersionEntry> entries,
|
||||
List<VersionPriority> priorities,
|
||||
) {
|
||||
entries.sort((a, b) => compareMultiSourceEntries(a, b, priorities));
|
||||
}
|
||||
|
||||
|
||||
int compareMultiSourceEntries(
|
||||
MultiSourceVersionEntry a,
|
||||
MultiSourceVersionEntry b,
|
||||
List<VersionPriority> priorities,
|
||||
) {
|
||||
final ba = kVersionBucketOrder.indexOf(a.bucket);
|
||||
final bb = kVersionBucketOrder.indexOf(b.bucket);
|
||||
if (ba != bb) return ba.compareTo(bb);
|
||||
|
||||
if (priorities.isNotEmpty) {
|
||||
final cmp = compareMediaSourcesByPriority(a.source, b.source, priorities);
|
||||
if (cmp != 0) return cmp;
|
||||
}
|
||||
|
||||
if (a.isLocal != b.isLocal) return a.isLocal ? -1 : 1;
|
||||
|
||||
if (a.isLocal && b.isLocal) return a.localIndex.compareTo(b.localIndex);
|
||||
final byServerName = a.serverName.compareTo(b.serverName);
|
||||
if (byServerName != 0) return byServerName;
|
||||
final byServerId = a.serverId.compareTo(b.serverId);
|
||||
if (byServerId != 0) return byServerId;
|
||||
final byItemId = a.itemId.compareTo(b.itemId);
|
||||
if (byItemId != 0) return byItemId;
|
||||
return a.mediaSourceId.compareTo(b.mediaSourceId);
|
||||
}
|
||||
|
||||
|
||||
String? defaultSelectedBucketForEntries(List<MultiSourceVersionEntry> entries) {
|
||||
if (entries.isEmpty) return null;
|
||||
for (final e in entries) {
|
||||
if (e.isActive) return e.bucket;
|
||||
}
|
||||
final present = entries.map((e) => e.bucket).toSet();
|
||||
for (final bucket in kVersionBucketOrder) {
|
||||
if (present.contains(bucket)) return bucket;
|
||||
}
|
||||
return entries.first.bucket;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
library;
|
||||
|
||||
|
||||
double multiSourceScrollOffset({
|
||||
required int activeIndex,
|
||||
required double viewportWidth,
|
||||
required double leadingPad,
|
||||
required double maxScrollExtent,
|
||||
double cardWidth = 220.0,
|
||||
double cardGap = 12.0,
|
||||
}) {
|
||||
if (activeIndex <= 0) return 0.0;
|
||||
final target =
|
||||
leadingPad +
|
||||
activeIndex * (cardWidth + cardGap) -
|
||||
(viewportWidth - cardWidth) / 2;
|
||||
return target.clamp(0.0, maxScrollExtent).toDouble();
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart' as forui;
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../providers/di_providers.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../providers/version_priority_provider.dart';
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/utils/cross_server_search_key.dart';
|
||||
import '../../../shared/widgets/all_versions_sheet.dart';
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../../shared/widgets/frosted_panel.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/version_filter.dart';
|
||||
import '../../../shared/widgets/version_grouping.dart';
|
||||
import '../../../shared/widgets/version_source_card.dart';
|
||||
import 'multi_source_ordering.dart';
|
||||
import 'multi_source_scroll.dart';
|
||||
|
||||
|
||||
typedef MultiSourceTap =
|
||||
Future<void> Function(
|
||||
String serverId,
|
||||
String itemId, {
|
||||
String? mediaSourceId,
|
||||
CrossServerSourceCard? card,
|
||||
});
|
||||
|
||||
|
||||
typedef LocalSourceTap = void Function(EmbyRawMediaSource src, int index);
|
||||
|
||||
class MultiSourceSection extends ConsumerStatefulWidget {
|
||||
final CrossServerSearchReq req;
|
||||
final MultiSourceTap? onSourceTap;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final List<EmbyRawMediaSource> localSources;
|
||||
|
||||
|
||||
final String? currentLocalSourceId;
|
||||
|
||||
|
||||
final String? currentServerName;
|
||||
|
||||
|
||||
final LocalSourceTap? onLocalSourceTap;
|
||||
|
||||
|
||||
final String? excludedServerIdOverride;
|
||||
|
||||
|
||||
final String title;
|
||||
|
||||
|
||||
final String? selectedCrossServerKey;
|
||||
|
||||
|
||||
final bool showLoadingWhenEmpty;
|
||||
|
||||
|
||||
final bool externalLoading;
|
||||
|
||||
|
||||
final String loadingText;
|
||||
|
||||
const MultiSourceSection({
|
||||
super.key,
|
||||
required this.req,
|
||||
this.onSourceTap,
|
||||
this.embedded = false,
|
||||
this.localSources = const [],
|
||||
this.currentLocalSourceId,
|
||||
this.currentServerName,
|
||||
this.onLocalSourceTap,
|
||||
this.excludedServerIdOverride,
|
||||
this.title = '多源聚合',
|
||||
this.selectedCrossServerKey,
|
||||
this.showLoadingWhenEmpty = false,
|
||||
this.externalLoading = false,
|
||||
this.loadingText = '正在查找可用版本…',
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<MultiSourceSection> createState() => _MultiSourceSectionState();
|
||||
}
|
||||
|
||||
String _bucketOf(CrossServerSourceCard card) {
|
||||
return versionBucketOf(card.quality?.resolutionLabel);
|
||||
}
|
||||
|
||||
String _bucketOfLocal(EmbyRawMediaSource src) {
|
||||
return versionBucketOf(MediaQualityInfo.fromMediaSource(src).resolutionLabel);
|
||||
}
|
||||
|
||||
class _MultiSourceSectionState extends ConsumerState<MultiSourceSection> {
|
||||
static final double _cardWidth = Platform.isAndroid ? 170.0 : 220.0;
|
||||
static const double _cardGap = 12.0;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
String? _selectedBucket;
|
||||
|
||||
String? _autoScrolledKey;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
|
||||
final req = widget.req;
|
||||
final key = (
|
||||
activeServerId: session.serverId,
|
||||
anchorType: req.anchorType,
|
||||
providerIdQuery: encodeProviderIdsForKey(req.providerIds),
|
||||
seriesProviderIdQuery: encodeProviderIdsForKey(
|
||||
req.seriesProviderIds ?? const {},
|
||||
),
|
||||
parentIndexNumber: req.parentIndexNumber ?? -1,
|
||||
indexNumber: req.indexNumber ?? -1,
|
||||
itemName: req.itemName,
|
||||
excludedServerId: widget.excludedServerIdOverride ?? session.serverId,
|
||||
);
|
||||
|
||||
final asyncResult = ref.watch(crossServerSearchDataProvider(key));
|
||||
|
||||
return _renderContent(
|
||||
asyncResult,
|
||||
widget.excludedServerIdOverride ?? session.serverId,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderContent(
|
||||
AsyncValue<List<CrossServerSourceCard>> asyncResult,
|
||||
String currentServerId,
|
||||
) {
|
||||
final cards = asyncResult.maybeWhen(
|
||||
data: (list) => list,
|
||||
orElse: () => asyncResult.value ?? const <CrossServerSourceCard>[],
|
||||
);
|
||||
|
||||
final priorities =
|
||||
ref.watch(versionPriorityProvider).value?.activePriorities ??
|
||||
const <VersionPriority>[];
|
||||
|
||||
final entries = <MultiSourceVersionEntry>[];
|
||||
final localSources = widget.localSources;
|
||||
for (var i = 0; i < localSources.length; i++) {
|
||||
final src = localSources[i];
|
||||
final isCurrent =
|
||||
widget.currentLocalSourceId != null &&
|
||||
src.Id == widget.currentLocalSourceId;
|
||||
entries.add(
|
||||
MultiSourceVersionEntry(
|
||||
bucket: _bucketOfLocal(src),
|
||||
data: _localCardData(src, i, currentServerId),
|
||||
source: src,
|
||||
isLocal: true,
|
||||
isActive: isCurrent,
|
||||
localIndex: i,
|
||||
serverName: widget.currentServerName ?? '本服务器',
|
||||
serverId: '',
|
||||
itemId: '',
|
||||
mediaSourceId: src.Id ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final c in cards) {
|
||||
final isSelected =
|
||||
widget.selectedCrossServerKey != null &&
|
||||
widget.selectedCrossServerKey == '${c.serverId}|${c.mediaSourceId}';
|
||||
entries.add(
|
||||
MultiSourceVersionEntry(
|
||||
bucket: _bucketOf(c),
|
||||
data: _crossServerCardData(c),
|
||||
source: c.mediaSource,
|
||||
isLocal: false,
|
||||
isActive: isSelected,
|
||||
localIndex: -1,
|
||||
serverName: c.serverName,
|
||||
serverId: c.serverId,
|
||||
itemId: c.landingItemId,
|
||||
mediaSourceId: c.mediaSourceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
sortMultiSourceEntries(entries, priorities);
|
||||
final allGroups = groupVersionEntries([
|
||||
for (final entry in entries) entry.data,
|
||||
]);
|
||||
|
||||
final bucketsPresent = <String>{};
|
||||
for (final e in entries) {
|
||||
bucketsPresent.add(e.bucket);
|
||||
}
|
||||
final buckets = kVersionBucketOrder
|
||||
.where(bucketsPresent.contains)
|
||||
.toList(growable: false);
|
||||
|
||||
final canonicalSelected =
|
||||
_selectedBucket != null && bucketsPresent.contains(_selectedBucket)
|
||||
? _selectedBucket!
|
||||
: defaultSelectedBucketForEntries(entries);
|
||||
|
||||
final filteredGroups = canonicalSelected == null
|
||||
? allGroups
|
||||
: allGroups
|
||||
.where(
|
||||
(group) =>
|
||||
versionBucketOf(group.representative.resolutionLabel) ==
|
||||
canonicalSelected,
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
final hasMergedGroup = allGroups.any((group) => group.members.length > 1);
|
||||
final showAllVersionsButton =
|
||||
filteredGroups.length > inlineVersionLimit() || hasMergedGroup;
|
||||
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final compact = width < Breakpoints.detail;
|
||||
final hPad = compact ? 16.0 : 32.0;
|
||||
|
||||
if (entries.isEmpty) {
|
||||
final loading = widget.externalLoading || asyncResult.isLoading;
|
||||
if (widget.showLoadingWhenEmpty && loading) {
|
||||
return _wrapContent(
|
||||
_buildLoadingContent(widget.embedded ? 0.0 : hPad),
|
||||
hPad,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(
|
||||
buckets,
|
||||
canonicalSelected,
|
||||
allGroups,
|
||||
entries.length,
|
||||
showAllVersionsButton,
|
||||
widget.embedded ? 0.0 : hPad,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildCardList(
|
||||
filteredGroups,
|
||||
canonicalSelected,
|
||||
widget.embedded ? 0.0 : hPad,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return _wrapContent(content, hPad);
|
||||
}
|
||||
|
||||
Widget _wrapContent(Widget content, double hPad) {
|
||||
if (widget.embedded) return content;
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
child: FrostedPanel(
|
||||
radius: 16,
|
||||
padding: EdgeInsets.all(hPad),
|
||||
blurSigma: 15,
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingContent(double hPad) {
|
||||
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 17 : 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const AppLoadingRing(size: 16, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.loadingText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: Platform.isAndroid ? 100 : 120),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(
|
||||
List<String> buckets,
|
||||
String? selectedBucket,
|
||||
List<VersionSourceGroup> allGroups,
|
||||
int sourceCount,
|
||||
bool showAllVersionsButton,
|
||||
double hPad,
|
||||
) {
|
||||
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
|
||||
final titleAndAction = Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 17 : 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
if (showAllVersionsButton)
|
||||
forui.FButton(
|
||||
variant: forui.FButtonVariant.secondary,
|
||||
size: forui.FButtonSizeVariant.xs,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
onPress: () =>
|
||||
showAllVersionsSheet(context: context, groups: allGroups),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('全部 $sourceCount 个版本'),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.chevron_right_rounded, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final filterChips = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (
|
||||
var bucketIndex = 0;
|
||||
bucketIndex < buckets.length;
|
||||
bucketIndex++
|
||||
) ...[
|
||||
if (bucketIndex > 0) const SizedBox(width: 8),
|
||||
VersionFilterChip(
|
||||
label: buckets[bucketIndex],
|
||||
selected: selectedBucket == buckets[bucketIndex],
|
||||
showDot: true,
|
||||
onTap: () => setState(() => _selectedBucket = buckets[bucketIndex]),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
child: compact
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
titleAndAction,
|
||||
if (buckets.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: filterChips,
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
titleAndAction,
|
||||
if (buckets.isNotEmpty) ...[
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
child: filterChips,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardList(
|
||||
List<VersionSourceGroup> groups,
|
||||
String? bucket,
|
||||
double hPad,
|
||||
) {
|
||||
if (groups.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final inlineLimit = inlineVersionLimit();
|
||||
final inlineGroups = groups.take(inlineLimit).toList(growable: true);
|
||||
final activeGroupIndex = groups.indexWhere(
|
||||
(group) => group.hasActiveMember,
|
||||
);
|
||||
if (activeGroupIndex >= inlineLimit && inlineGroups.isNotEmpty) {
|
||||
inlineGroups[inlineGroups.length - 1] = groups[activeGroupIndex];
|
||||
}
|
||||
|
||||
_maybeScrollActiveIntoView(inlineGroups, bucket, hPad);
|
||||
final itemCount = inlineGroups.length;
|
||||
|
||||
return SizedBox(
|
||||
height: Platform.isAndroid ? 100 : 120,
|
||||
child: HoverScrollableRow(
|
||||
controller: _scrollController,
|
||||
builder: (_, controller) => ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 0),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (_, i) => Padding(
|
||||
padding: EdgeInsets.only(right: i < itemCount - 1 ? 12 : 0),
|
||||
child: VersionSourceCard(
|
||||
data: inlineGroups[i].displayData,
|
||||
onMergedServersTap: inlineGroups[i].members.length > 1
|
||||
? () => showAllVersionsSheet(
|
||||
context: context,
|
||||
groups: [inlineGroups[i]],
|
||||
title: '选择服务器',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _maybeScrollActiveIntoView(
|
||||
List<VersionSourceGroup> groups,
|
||||
String? bucket,
|
||||
double leadingPad,
|
||||
) {
|
||||
final activeGroupIndex = groups.indexWhere(
|
||||
(group) => group.hasActiveMember,
|
||||
);
|
||||
final activeSourceKey = activeGroupIndex < 0
|
||||
? '<none>'
|
||||
: groups[activeGroupIndex].activeMember?.sourceKey ?? '<none>';
|
||||
final key = '${bucket ?? '<all>'}|$activeSourceKey';
|
||||
if (key == _autoScrolledKey) return;
|
||||
_autoScrolledKey = key;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
final position = _scrollController.position;
|
||||
_scrollController.jumpTo(
|
||||
multiSourceScrollOffset(
|
||||
activeIndex: activeGroupIndex,
|
||||
viewportWidth: position.viewportDimension,
|
||||
leadingPad: leadingPad,
|
||||
maxScrollExtent: position.maxScrollExtent,
|
||||
cardWidth: _cardWidth,
|
||||
cardGap: _cardGap,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
VersionSourceCardData _localCardData(
|
||||
EmbyRawMediaSource src,
|
||||
int index,
|
||||
String currentServerId,
|
||||
) {
|
||||
final tap = widget.onLocalSourceTap;
|
||||
final isCurrent =
|
||||
widget.currentLocalSourceId != null &&
|
||||
src.Id == widget.currentLocalSourceId;
|
||||
return VersionSourceCardData.fromLocalSource(
|
||||
src,
|
||||
serverName: widget.currentServerName ?? '本服务器',
|
||||
serverKey: currentServerId,
|
||||
isCurrent: isCurrent,
|
||||
tooltip: isCurrent ? null : '切换到该版本',
|
||||
onTap: (tap == null || isCurrent) ? null : () async => tap(src, index),
|
||||
);
|
||||
}
|
||||
|
||||
VersionSourceCardData _crossServerCardData(CrossServerSourceCard card) {
|
||||
final selectedKey = widget.selectedCrossServerKey;
|
||||
final isSelected =
|
||||
selectedKey != null &&
|
||||
selectedKey == '${card.serverId}|${card.mediaSourceId}';
|
||||
final data = VersionSourceCardData.fromCrossServer(
|
||||
card,
|
||||
selected: isSelected,
|
||||
onTap: _tapHandlerFor(card),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> Function()? _tapHandlerFor(CrossServerSourceCard card) {
|
||||
final tap = widget.onSourceTap;
|
||||
if (tap == null) return null;
|
||||
return () => tap(
|
||||
card.serverId,
|
||||
card.landingItemId,
|
||||
mediaSourceId: card.mediaSourceId,
|
||||
card: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/widgets/frosted_panel.dart';
|
||||
|
||||
class SectionContainer extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
final bool embedded;
|
||||
final bool leadingDivider;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const SectionContainer({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.embedded = false,
|
||||
this.leadingDivider = false,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
static double hPadOf(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width < Breakpoints.detail ? 16.0 : 32.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hPad = embedded ? 0.0 : hPadOf(context);
|
||||
final innerPad = embedded ? 0.0 : hPadOf(context);
|
||||
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
innerPad,
|
||||
0,
|
||||
innerPad,
|
||||
compact ? 12 : 16,
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 17 : 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
if (leading != null) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [leading!, content],
|
||||
);
|
||||
}
|
||||
if (leadingDivider) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const FDivider(style: .delta(color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.symmetric(vertical: 24)))),
|
||||
content,
|
||||
],
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad),
|
||||
child: FrostedPanel(
|
||||
radius: 16,
|
||||
padding: EdgeInsets.symmetric(vertical: hPad),
|
||||
blurSigma: 15,
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/di_providers.dart';
|
||||
import '../../../providers/emby_headers_provider.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../shared/mappers/media_image_url.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/media_nav.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/media_poster_card.dart';
|
||||
import 'section_container.dart';
|
||||
|
||||
class SimilarSection extends ConsumerWidget {
|
||||
final String itemId;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const SimilarSection({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
this.embedded = false,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
final asyncItems = ref.watch(similarItemsDataProvider(itemId));
|
||||
|
||||
return asyncItems.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stackTrace) => const SizedBox.shrink(),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
final sectionPad = SectionContainer.hPadOf(context);
|
||||
final listPadding = EdgeInsetsDirectional.fromSTEB(
|
||||
embedded ? 0.0 : sectionPad,
|
||||
0,
|
||||
sectionPad,
|
||||
0,
|
||||
);
|
||||
|
||||
final isAndroid = Platform.isAndroid;
|
||||
return SectionContainer(
|
||||
title: '相似推荐',
|
||||
embedded: embedded,
|
||||
leadingDivider: embedded,
|
||||
leading: leading,
|
||||
child: SizedBox(
|
||||
height: isAndroid ? 210 : 300,
|
||||
child: Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, controller) => ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: listPadding,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final item = items[i];
|
||||
final imageUrl = EmbyImageUrl.primary(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: item,
|
||||
maxHeight: 480,
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: i < items.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: MediaPosterCard(
|
||||
item: item,
|
||||
imageUrl: imageUrl,
|
||||
width: isAndroid ? 105 : 160,
|
||||
showHoverPlayIcon: false,
|
||||
enableContextMenu: false,
|
||||
preloadImageUrl: EmbyImageUrl.detailBanner(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: item,
|
||||
),
|
||||
imageHeaders: imageHeaders,
|
||||
onTap: () => goMediaDetail(context, item),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../providers/di_providers.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../shared/utils/cross_server_search_key.dart';
|
||||
import 'media_info_section.dart';
|
||||
import 'multi_source_section.dart';
|
||||
|
||||
|
||||
class SourceSelectionSection extends ConsumerWidget {
|
||||
|
||||
|
||||
final CrossServerSearchReq? req;
|
||||
|
||||
|
||||
final List<EmbyRawMediaSource> localSources;
|
||||
|
||||
|
||||
final int selectedSourceIdx;
|
||||
|
||||
|
||||
final CrossServerSourceCard? selectedCrossServerCard;
|
||||
|
||||
|
||||
final String? currentServerName;
|
||||
|
||||
|
||||
final String? excludedServerIdOverride;
|
||||
|
||||
|
||||
final EmbyRawItem? localMediaInfoItem;
|
||||
|
||||
|
||||
final void Function(EmbyRawMediaSource src, int index) onLocalSourceTap;
|
||||
|
||||
|
||||
final void Function(CrossServerSourceCard card) onCrossServerSourceTap;
|
||||
|
||||
|
||||
final String title;
|
||||
|
||||
|
||||
final bool showLoadingWhenEmpty;
|
||||
final bool externalLoading;
|
||||
final String loadingText;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const SourceSelectionSection({
|
||||
super.key,
|
||||
required this.req,
|
||||
required this.localSources,
|
||||
required this.selectedSourceIdx,
|
||||
required this.selectedCrossServerCard,
|
||||
required this.onLocalSourceTap,
|
||||
required this.onCrossServerSourceTap,
|
||||
this.currentServerName,
|
||||
this.excludedServerIdOverride,
|
||||
this.localMediaInfoItem,
|
||||
this.title = '多源聚合',
|
||||
this.showLoadingWhenEmpty = false,
|
||||
this.externalLoading = false,
|
||||
this.loadingText = '正在查找可用版本…',
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchReq = req;
|
||||
final card = selectedCrossServerCard;
|
||||
|
||||
final currentLocalSourceId =
|
||||
card == null && selectedSourceIdx < localSources.length
|
||||
? localSources[selectedSourceIdx].Id
|
||||
: null;
|
||||
final selectedCrossServerKey = card != null
|
||||
? '${card.serverId}|${card.mediaSourceId}'
|
||||
: null;
|
||||
|
||||
final EmbyRawMediaSource? mediaSource =
|
||||
card?.mediaSource ??
|
||||
(selectedSourceIdx < localSources.length
|
||||
? localSources[selectedSourceIdx]
|
||||
: null);
|
||||
final EmbyRawItem? mediaItem = card?.item ?? localMediaInfoItem;
|
||||
final multiSourceVisibility = searchReq == null
|
||||
? _MultiSourceVisibility.hidden
|
||||
: _multiSourceVisibility(ref, searchReq);
|
||||
final mediaInfo =
|
||||
mediaSource != null &&
|
||||
mediaItem != null &&
|
||||
MediaInfoSection.hasVisibleContent(
|
||||
item: mediaItem,
|
||||
selectedSource: mediaSource,
|
||||
)
|
||||
? MediaInfoSection(
|
||||
item: mediaItem,
|
||||
selectedSource: mediaSource,
|
||||
embedded: true,
|
||||
)
|
||||
: null;
|
||||
final bool hasContent =
|
||||
multiSourceVisibility != _MultiSourceVisibility.hidden ||
|
||||
mediaInfo != null;
|
||||
if (!hasContent) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(height: 16)],
|
||||
if (searchReq != null)
|
||||
MultiSourceSection(
|
||||
req: searchReq,
|
||||
embedded: true,
|
||||
localSources: localSources,
|
||||
currentLocalSourceId: currentLocalSourceId,
|
||||
currentServerName: currentServerName,
|
||||
excludedServerIdOverride: excludedServerIdOverride,
|
||||
selectedCrossServerKey: selectedCrossServerKey,
|
||||
title: title,
|
||||
showLoadingWhenEmpty: showLoadingWhenEmpty,
|
||||
externalLoading: externalLoading,
|
||||
loadingText: loadingText,
|
||||
onLocalSourceTap: onLocalSourceTap,
|
||||
onSourceTap: (serverId, itemId, {mediaSourceId, card}) async {
|
||||
if (card != null) onCrossServerSourceTap(card);
|
||||
},
|
||||
),
|
||||
if (mediaInfo != null) mediaInfo,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
_MultiSourceVisibility _multiSourceVisibility(
|
||||
WidgetRef ref,
|
||||
CrossServerSearchReq searchReq,
|
||||
) {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return _MultiSourceVisibility.hidden;
|
||||
|
||||
final key = (
|
||||
activeServerId: session.serverId,
|
||||
anchorType: searchReq.anchorType,
|
||||
providerIdQuery: encodeProviderIdsForKey(searchReq.providerIds),
|
||||
seriesProviderIdQuery: encodeProviderIdsForKey(
|
||||
searchReq.seriesProviderIds ?? const {},
|
||||
),
|
||||
parentIndexNumber: searchReq.parentIndexNumber ?? -1,
|
||||
indexNumber: searchReq.indexNumber ?? -1,
|
||||
itemName: searchReq.itemName,
|
||||
excludedServerId: excludedServerIdOverride ?? session.serverId,
|
||||
);
|
||||
final asyncResult = ref.watch(crossServerSearchDataProvider(key));
|
||||
final remoteCards = asyncResult.maybeWhen(
|
||||
data: (list) => list,
|
||||
orElse: () => asyncResult.value ?? const <CrossServerSourceCard>[],
|
||||
);
|
||||
if (localSources.isNotEmpty || remoteCards.isNotEmpty) {
|
||||
return _MultiSourceVisibility.content;
|
||||
}
|
||||
|
||||
final loading = externalLoading || asyncResult.isLoading;
|
||||
if (showLoadingWhenEmpty && loading) return _MultiSourceVisibility.loading;
|
||||
|
||||
return _MultiSourceVisibility.hidden;
|
||||
}
|
||||
}
|
||||
|
||||
enum _MultiSourceVisibility { hidden, loading, content }
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/di_providers.dart';
|
||||
import '../../../providers/emby_headers_provider.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../shared/mappers/media_image_url.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/media_nav.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/media_poster_card.dart';
|
||||
import 'section_container.dart';
|
||||
|
||||
class SpecialFeaturesSection extends ConsumerWidget {
|
||||
final String itemId;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const SpecialFeaturesSection({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
this.embedded = false,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
final asyncItems = ref.watch(specialFeaturesDataProvider(itemId));
|
||||
|
||||
return asyncItems.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stackTrace) => const SizedBox.shrink(),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
final sectionPad = SectionContainer.hPadOf(context);
|
||||
final listPadding = EdgeInsetsDirectional.fromSTEB(
|
||||
embedded ? 0.0 : sectionPad,
|
||||
0,
|
||||
sectionPad,
|
||||
0,
|
||||
);
|
||||
|
||||
final isAndroid = Platform.isAndroid;
|
||||
return SectionContainer(
|
||||
title: '花絮',
|
||||
embedded: embedded,
|
||||
leadingDivider: embedded,
|
||||
leading: leading,
|
||||
child: SizedBox(
|
||||
height: isAndroid ? 210 : 300,
|
||||
child: Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, controller) => ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: listPadding,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final item = items[i];
|
||||
final imageUrl = EmbyImageUrl.primary(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: item,
|
||||
maxHeight: 480,
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: i < items.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: MediaPosterCard(
|
||||
item: item,
|
||||
imageUrl: imageUrl,
|
||||
width: isAndroid ? 105 : 160,
|
||||
showHoverPlayIcon: false,
|
||||
enableContextMenu: false,
|
||||
imageHeaders: imageHeaders,
|
||||
onTap: () => goMediaDetail(context, item, ref: ref),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import 'hero_action_buttons.dart';
|
||||
|
||||
|
||||
List<FItem> buildSubtitleMenuButtons({
|
||||
required List<EmbyRawMediaStream> subtitles,
|
||||
required int? selectedSubtitleIdx,
|
||||
required ValueChanged<int?> onSelectSubtitle,
|
||||
}) {
|
||||
return [
|
||||
FItem(
|
||||
prefix: selectedSubtitleIdx == null
|
||||
? const Icon(Icons.check, size: 14)
|
||||
: const SizedBox(width: 14),
|
||||
title: const Text('关闭字幕'),
|
||||
selected: selectedSubtitleIdx == null,
|
||||
onPress: () => onSelectSubtitle(null),
|
||||
),
|
||||
...subtitles.asMap().entries.map(
|
||||
(e) => FItem(
|
||||
prefix: selectedSubtitleIdx == e.key
|
||||
? const Icon(Icons.check, size: 14)
|
||||
: const SizedBox(width: 14),
|
||||
title: Text(
|
||||
e.value.DisplayTitle ?? e.value.Language ?? '字幕 ${e.key + 1}',
|
||||
),
|
||||
selected: selectedSubtitleIdx == e.key,
|
||||
onPress: () => onSelectSubtitle(e.key),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
List<FItem> buildAudioMenuButtons({
|
||||
required List<EmbyRawMediaStream> audios,
|
||||
required int selectedAudioIdx,
|
||||
required ValueChanged<int> onSelectAudio,
|
||||
}) {
|
||||
return audios
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(e) => FItem(
|
||||
prefix: selectedAudioIdx == e.key
|
||||
? const Icon(Icons.check, size: 14)
|
||||
: const SizedBox(width: 14),
|
||||
title: Text(
|
||||
e.value.DisplayTitle ?? e.value.Language ?? '音轨 ${e.key + 1}',
|
||||
),
|
||||
selected: selectedAudioIdx == e.key,
|
||||
onPress: () => onSelectAudio(e.key),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
List<FItem> buildVersionMenuButtons({
|
||||
required List<EmbyRawMediaSource> sources,
|
||||
required int selectedSourceIdx,
|
||||
required ValueChanged<int> onSelectSource,
|
||||
}) {
|
||||
return sources.asMap().entries.map((e) {
|
||||
final src = e.value;
|
||||
final name = src.Name ?? '版本 ${e.key + 1}';
|
||||
final size = src.Size;
|
||||
final sizeStr = size != null && size > 0 ? formatFileSize(size) : null;
|
||||
return FItem(
|
||||
prefix: selectedSourceIdx == e.key
|
||||
? const Icon(Icons.check, size: 14)
|
||||
: const SizedBox(width: 14),
|
||||
title: Text(sizeStr != null ? '$name $sizeStr' : name),
|
||||
selected: selectedSourceIdx == e.key,
|
||||
onPress: () => onSelectSource(e.key),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
String subtitleSummaryLabel(
|
||||
List<EmbyRawMediaStream> subtitles,
|
||||
int? selectedSubtitleIdx,
|
||||
) => _streamSummaryLabel(
|
||||
subtitles,
|
||||
selectedSubtitleIdx,
|
||||
empty: '关闭',
|
||||
prefix: '字幕',
|
||||
);
|
||||
|
||||
|
||||
String audioSummaryLabel(
|
||||
List<EmbyRawMediaStream> audios,
|
||||
int selectedAudioIdx,
|
||||
) => _streamSummaryLabel(audios, selectedAudioIdx, empty: '默认', prefix: '音轨');
|
||||
|
||||
String _streamSummaryLabel(
|
||||
List<EmbyRawMediaStream> streams,
|
||||
int? idx, {
|
||||
required String empty,
|
||||
required String prefix,
|
||||
}) {
|
||||
if (idx == null || idx < 0 || idx >= streams.length) return empty;
|
||||
final s = streams[idx];
|
||||
return s.Language ?? s.DisplayTitle ?? '$prefix ${idx + 1}';
|
||||
}
|
||||
|
||||
|
||||
String versionSummaryLabel(
|
||||
List<EmbyRawMediaSource> sources,
|
||||
int selectedSourceIdx,
|
||||
) {
|
||||
if (selectedSourceIdx < 0 || selectedSourceIdx >= sources.length) {
|
||||
return '版本';
|
||||
}
|
||||
return sources[selectedSourceIdx].Name ?? '版本 ${selectedSourceIdx + 1}';
|
||||
}
|
||||
|
||||
|
||||
List<Widget> buildStreamSelectorPills({
|
||||
required List<EmbyRawMediaStream> subtitles,
|
||||
required List<EmbyRawMediaStream> audios,
|
||||
required int? selectedSubtitleIdx,
|
||||
required int selectedAudioIdx,
|
||||
required ValueChanged<int?> onSelectSubtitle,
|
||||
required ValueChanged<int> onSelectAudio,
|
||||
required double height,
|
||||
}) {
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (subtitles.length > 1) {
|
||||
actions.add(
|
||||
HeroPillDropdownButton(
|
||||
height: height,
|
||||
icon: Icons.subtitles_outlined,
|
||||
menuChildren: buildSubtitleMenuButtons(
|
||||
subtitles: subtitles,
|
||||
selectedSubtitleIdx: selectedSubtitleIdx,
|
||||
onSelectSubtitle: onSelectSubtitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (audios.length > 1) {
|
||||
actions.add(
|
||||
HeroPillDropdownButton(
|
||||
height: height,
|
||||
icon: Icons.audiotrack_outlined,
|
||||
menuChildren: buildAudioMenuButtons(
|
||||
audios: audios,
|
||||
selectedAudioIdx: selectedAudioIdx,
|
||||
onSelectAudio: onSelectAudio,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/contracts/tmdb.dart';
|
||||
import '../../../providers/person_items_provider.dart';
|
||||
import '../../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../../shared/widgets/cast_scroller.dart';
|
||||
import '../../../shared/widgets/section_header.dart';
|
||||
|
||||
|
||||
class TmdbCastSection extends ConsumerWidget {
|
||||
final List<TmdbCastMember> cast;
|
||||
final String imageBaseUrl;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const TmdbCastSection({
|
||||
super.key,
|
||||
required this.cast,
|
||||
required this.imageBaseUrl,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (cast.isEmpty) return const SizedBox.shrink();
|
||||
final entries = cast.map((member) {
|
||||
return CastEntry(
|
||||
name: member.name,
|
||||
subtitle: member.character,
|
||||
imageUrl: TmdbImageUrl.profile(imageBaseUrl, member.profilePath),
|
||||
onTap: () => _openPerson(context, ref, member.name),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 16, 0, 4),
|
||||
child: SectionHeader(label: '演职人员'),
|
||||
),
|
||||
CastScroller(entries: entries),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPerson(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String name,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
String? personId;
|
||||
try {
|
||||
personId = await ref.read(embyPersonIdByNameProvider(name).future);
|
||||
} catch (_) {
|
||||
personId = null;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (personId != null && personId.isNotEmpty) {
|
||||
context.push('/person/$personId');
|
||||
} else {
|
||||
messenger?.showSnackBar(SnackBar(content: Text('未在媒体库中找到「$name」的作品')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/tmdb.dart';
|
||||
import '../../../shared/utils/media_nav.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/section_header.dart';
|
||||
import '../../../shared/widgets/tmdb_poster_card.dart';
|
||||
|
||||
|
||||
class TmdbRecommendationSection extends StatelessWidget {
|
||||
final List<TmdbRecommendation> items;
|
||||
final String imageBaseUrl;
|
||||
final String fallbackMediaType;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const TmdbRecommendationSection({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.imageBaseUrl,
|
||||
required this.fallbackMediaType,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
final list = items.length > 20 ? items.sublist(0, 20) : items;
|
||||
final isAndroid = Platform.isAndroid;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 16, 0, 4),
|
||||
child: SectionHeader(label: '推荐'),
|
||||
),
|
||||
SizedBox(
|
||||
height: isAndroid ? 210 : 280,
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, controller) => ListView.separated(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (_, i) {
|
||||
final item = list[i];
|
||||
final type = item.mediaType ?? fallbackMediaType;
|
||||
final tappable = type == 'movie' || type == 'tv';
|
||||
return TmdbPosterCard(
|
||||
title: item.title,
|
||||
posterPath: item.posterPath,
|
||||
voteAverage: item.voteAverage,
|
||||
mediaTypeLabel: _typeLabel(item.mediaType),
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
width: isAndroid ? 105 : 150,
|
||||
onTap: tappable
|
||||
? () => goTmdbDetail(
|
||||
context,
|
||||
tmdbId: item.id,
|
||||
mediaType: type,
|
||||
seed: item,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String? _typeLabel(String? mediaType) => switch (mediaType) {
|
||||
'tv' => '剧集',
|
||||
'movie' => '电影',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
String? mediaTypeLabel(String? mediaType) => switch (mediaType) {
|
||||
'tv' => '剧集',
|
||||
'movie' => '电影',
|
||||
_ => null,
|
||||
};
|
||||
@@ -0,0 +1,546 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import '../../core/contracts/tmdb.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/home_page_mode_provider.dart';
|
||||
import '../../providers/shell_backdrop_provider.dart';
|
||||
import '../../providers/sm_account_provider.dart';
|
||||
import '../../providers/tmdb_settings_provider.dart';
|
||||
import '../../shared/constants/breakpoints.dart';
|
||||
import '../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../shared/utils/color_utils.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../shared/widgets/media_row_metrics.dart';
|
||||
import '../../shared/widgets/media_poster_card.dart';
|
||||
import '../../shared/widgets/scroll_fade_header.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/widgets/section_header.dart';
|
||||
import '../../shared/widgets/skeleton_media_row.dart';
|
||||
import '../../shared/widgets/tmdb_poster_card.dart';
|
||||
import '../home/widgets/banner_carousel.dart' show HomeBannerSkeleton;
|
||||
import '../home/widgets/server_dropdown_button.dart';
|
||||
import 'discover_helpers.dart';
|
||||
import 'widgets/tmdb_banner_carousel.dart';
|
||||
import 'widgets/trakt_continue_watching_row.dart';
|
||||
|
||||
|
||||
String _smAccountHint(SmAuthStatus? status) => switch (status) {
|
||||
SmAuthStatus.expired => '登录已过期,请重新登录后再浏览推荐内容。',
|
||||
SmAuthStatus.degraded => '账号凭据不完整,请重新登录后再浏览推荐内容。',
|
||||
_ => '需要登录 sm-server 账号后才能浏览推荐内容。',
|
||||
};
|
||||
|
||||
|
||||
class _DiscoverRowSpec {
|
||||
final String title;
|
||||
final String mediaType;
|
||||
final String sortBy;
|
||||
final String? withGenres;
|
||||
final String? voteCountGte;
|
||||
|
||||
const _DiscoverRowSpec({
|
||||
required this.title,
|
||||
required this.mediaType,
|
||||
this.sortBy = 'popularity.desc',
|
||||
this.withGenres,
|
||||
this.voteCountGte,
|
||||
});
|
||||
|
||||
TmdbDiscoverKey get key => (
|
||||
mediaType: mediaType,
|
||||
sortBy: sortBy,
|
||||
withGenres: withGenres,
|
||||
voteCountGte: voteCountGte,
|
||||
);
|
||||
}
|
||||
|
||||
const _discoverRows = <_DiscoverRowSpec>[
|
||||
_DiscoverRowSpec(
|
||||
title: '高分剧集',
|
||||
mediaType: 'tv',
|
||||
sortBy: 'vote_average.desc',
|
||||
voteCountGte: '200',
|
||||
),
|
||||
_DiscoverRowSpec(title: '科幻电影', mediaType: 'movie', withGenres: '878'),
|
||||
_DiscoverRowSpec(title: '动画', mediaType: 'movie', withGenres: '16'),
|
||||
_DiscoverRowSpec(title: '真人秀', mediaType: 'tv', withGenres: '10764'),
|
||||
_DiscoverRowSpec(title: '纪录片', mediaType: 'movie', withGenres: '99'),
|
||||
];
|
||||
|
||||
class DiscoverPage extends ConsumerStatefulWidget {
|
||||
final bool embedInHome;
|
||||
|
||||
const DiscoverPage({super.key, this.embedInHome = false});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverPage> createState() => _DiscoverPageState();
|
||||
}
|
||||
|
||||
class _DiscoverPageState extends ConsumerState<DiscoverPage> {
|
||||
TmdbRecommendation? _lastBannerItem;
|
||||
String? _lastBaseUrl;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ValueNotifier<double> _scrollProgress = ValueNotifier<double>(0.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
_scrollController.dispose();
|
||||
_scrollProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
const threshold = 120.0;
|
||||
final progress = (_scrollController.offset / threshold).clamp(0.0, 1.0);
|
||||
_scrollProgress.value = progress;
|
||||
}
|
||||
|
||||
|
||||
void _onBannerActiveItem(TmdbRecommendation item, String baseUrl) {
|
||||
_lastBannerItem = item;
|
||||
_lastBaseUrl = baseUrl;
|
||||
_writeBackdropIfActive();
|
||||
}
|
||||
|
||||
void _writeBackdropIfActive() {
|
||||
if (!_isActiveRoute()) return;
|
||||
final item = _lastBannerItem;
|
||||
final baseUrl = _lastBaseUrl;
|
||||
if (item == null || baseUrl == null) return;
|
||||
final url = TmdbImageUrl.backdrop(baseUrl, item.backdropPath);
|
||||
if (url == null) return;
|
||||
final state = ShellBackdropState(
|
||||
primaryImageUrl: url,
|
||||
fallbackImageUrls: [url],
|
||||
accentColor: _accentColorFor(item.id.toString()),
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isActiveRoute()) return;
|
||||
ref.read(shellBackdropProvider.notifier).show(state);
|
||||
});
|
||||
}
|
||||
|
||||
bool _isActiveRoute() {
|
||||
final location = ref.read(shellLocationProvider);
|
||||
if (location == '/discover') return true;
|
||||
return widget.embedInHome &&
|
||||
location == '/' &&
|
||||
ref.read(homePageModeProvider) == HomePageMode.recommend;
|
||||
}
|
||||
|
||||
|
||||
void _onNoBanner() {
|
||||
_lastBannerItem = null;
|
||||
_lastBaseUrl = null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isActiveRoute()) return;
|
||||
if (ref.read(shellBackdropProvider) == null) return;
|
||||
ref.read(shellBackdropProvider.notifier).clear();
|
||||
});
|
||||
}
|
||||
|
||||
Color _accentColorFor(String seed) => accentColorForSeed(
|
||||
seed,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
);
|
||||
|
||||
Future<void> _refresh() async {
|
||||
ref.invalidate(tmdbTrendingDayProvider);
|
||||
ref.invalidate(tmdbTrendingWeekProvider);
|
||||
ref.invalidate(tmdbPopularMoviesProvider);
|
||||
ref.invalidate(tmdbPopularTvProvider);
|
||||
ref.invalidate(tmdbAiringTodayTvProvider);
|
||||
ref.invalidate(tmdbTopRatedMoviesProvider);
|
||||
for (final spec in _discoverRows) {
|
||||
ref.invalidate(tmdbDiscoverProvider(spec.key));
|
||||
}
|
||||
ref.invalidate(traktContinueWatchingProvider);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsAsync = ref.watch(tmdbSettingsProvider);
|
||||
|
||||
ref.listen<String>(shellLocationProvider, (_, loc) {
|
||||
if (loc != '/discover' && !(widget.embedInHome && loc == '/')) return;
|
||||
if (_lastBannerItem != null) {
|
||||
_writeBackdropIfActive();
|
||||
} else {
|
||||
_onNoBanner();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: settingsAsync.when(
|
||||
loading: () {
|
||||
_onNoBanner();
|
||||
return const _DiscoverSkeleton();
|
||||
},
|
||||
error: (_, _) {
|
||||
_onNoBanner();
|
||||
return AppErrorState(
|
||||
title: 'TMDB 设置加载失败',
|
||||
onRetry: () => ref.invalidate(tmdbSettingsProvider),
|
||||
);
|
||||
},
|
||||
data: (settings) {
|
||||
final accountAsync = ref.watch(smAccountProvider);
|
||||
final account = accountAsync.value;
|
||||
final needsAccount =
|
||||
settings.accessMode == TmdbAccessMode.smAccount &&
|
||||
settings.enabled;
|
||||
if (needsAccount && account == null && accountAsync.isLoading) {
|
||||
_onNoBanner();
|
||||
return const _DiscoverSkeleton();
|
||||
}
|
||||
final canRequest = settings.accessMode == TmdbAccessMode.smAccount
|
||||
? settings.enabled && (account?.hasActiveSession ?? false)
|
||||
: settings.canRequest;
|
||||
if (!canRequest) {
|
||||
_onNoBanner();
|
||||
final message = settings.accessMode == TmdbAccessMode.smAccount
|
||||
? _smAccountHint(account?.status)
|
||||
: '需要在本地配置 TMDB API Key 或读取令牌后才能浏览推荐内容。';
|
||||
return Center(
|
||||
child: EmptyState(
|
||||
icon: Icons.explore_off_outlined,
|
||||
title: 'TMDB 未配置',
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sections = <Widget>[
|
||||
_DiscoverSection(
|
||||
title: '今日热门',
|
||||
provider: tmdbTrendingDayProvider,
|
||||
imageBaseUrl: settings.effectiveImageBaseUrl,
|
||||
),
|
||||
_DiscoverSection(
|
||||
title: '本周热门',
|
||||
provider: tmdbTrendingWeekProvider,
|
||||
imageBaseUrl: settings.effectiveImageBaseUrl,
|
||||
),
|
||||
_DiscoverSection(
|
||||
title: '热门电影',
|
||||
provider: tmdbPopularMoviesProvider,
|
||||
imageBaseUrl: settings.effectiveImageBaseUrl,
|
||||
fallbackMediaType: 'movie',
|
||||
),
|
||||
_DiscoverSection(
|
||||
title: '热门剧集',
|
||||
provider: tmdbPopularTvProvider,
|
||||
imageBaseUrl: settings.effectiveImageBaseUrl,
|
||||
fallbackMediaType: 'tv',
|
||||
),
|
||||
_DiscoverSection(
|
||||
title: '高分电影',
|
||||
provider: tmdbTopRatedMoviesProvider,
|
||||
imageBaseUrl: settings.effectiveImageBaseUrl,
|
||||
fallbackMediaType: 'movie',
|
||||
),
|
||||
for (final spec in _discoverRows)
|
||||
_DiscoverSection(
|
||||
title: spec.title,
|
||||
provider: tmdbDiscoverProvider(spec.key),
|
||||
imageBaseUrl: settings.effectiveImageBaseUrl,
|
||||
fallbackMediaType: spec.mediaType,
|
||||
),
|
||||
];
|
||||
|
||||
final tokens = context.appTokens;
|
||||
final isDesktop = Platform.isMacOS || Platform.isWindows;
|
||||
final isCompact = Breakpoints.isCompact(
|
||||
MediaQuery.sizeOf(context).width,
|
||||
);
|
||||
final topPad = isDesktop
|
||||
? tokens.titleBarHeight
|
||||
: math.max(
|
||||
MediaQuery.paddingOf(context).top,
|
||||
tokens.titleBarHeight,
|
||||
);
|
||||
final listTopPad = isCompact ? 0.0 : topPad + 30;
|
||||
final listBottomPad = widget.embedInHome ? 116.0 : 32.0;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
top: listTopPad,
|
||||
bottom: listBottomPad,
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: isCompact ? 0 : 12,
|
||||
bottom: 4,
|
||||
),
|
||||
child: _DiscoverBanner(
|
||||
onActiveItem: _onBannerActiveItem,
|
||||
onNoBanner: _onNoBanner,
|
||||
),
|
||||
),
|
||||
const TraktContinueWatchingRow(),
|
||||
...sections,
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ScrollFadeHeader(
|
||||
scrollProgress: _scrollProgress,
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: 38,
|
||||
height: 38,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: '刷新',
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => unawaited(_refresh()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.embedInHome)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 8,
|
||||
left: 16,
|
||||
child: const ServerDropdownButton(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscoverSkeleton extends StatelessWidget {
|
||||
const _DiscoverSkeleton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.appTokens;
|
||||
final isDesktop = Platform.isMacOS || Platform.isWindows;
|
||||
final topPad = isDesktop
|
||||
? tokens.titleBarHeight
|
||||
: math.max(MediaQuery.paddingOf(context).top, tokens.titleBarHeight);
|
||||
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
|
||||
final listTopPad = isCompact ? 0.0 : topPad + 30;
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.only(top: listTopPad, bottom: 32),
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: isCompact ? 0 : 12, bottom: 4),
|
||||
child: const HomeBannerSkeleton(),
|
||||
),
|
||||
const SkeletonMediaRow(
|
||||
title: '继续观看',
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
titleColor: Colors.white,
|
||||
),
|
||||
const SkeletonMediaRow(title: '今日热门', titleColor: Colors.white),
|
||||
const SkeletonMediaRow(title: '本周热门', titleColor: Colors.white),
|
||||
const SkeletonMediaRow(title: '热门电影', titleColor: Colors.white),
|
||||
const SkeletonMediaRow(title: '热门剧集', titleColor: Colors.white),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _DiscoverBanner extends ConsumerWidget {
|
||||
|
||||
final void Function(TmdbRecommendation item, String baseUrl) onActiveItem;
|
||||
|
||||
|
||||
final VoidCallback onNoBanner;
|
||||
|
||||
const _DiscoverBanner({required this.onActiveItem, required this.onNoBanner});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(tmdbSettingsProvider).value;
|
||||
final asyncData = ref.watch(tmdbAiringTodayTvProvider);
|
||||
|
||||
return asyncData.when(
|
||||
loading: () {
|
||||
onNoBanner();
|
||||
return const HomeBannerSkeleton();
|
||||
},
|
||||
error: (_, _) {
|
||||
onNoBanner();
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
data: (res) {
|
||||
if (res == null || settings == null) {
|
||||
onNoBanner();
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final items = res.results
|
||||
.where((e) => e.backdropPath?.isNotEmpty ?? false)
|
||||
.map((e) => e.mediaType == 'tv' ? e : e.copyWith(mediaType: 'tv'))
|
||||
.take(6)
|
||||
.toList();
|
||||
if (items.isEmpty) {
|
||||
onNoBanner();
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final baseUrl = settings.effectiveImageBaseUrl;
|
||||
return TmdbBannerCarousel(
|
||||
items: items,
|
||||
imageBaseUrl: baseUrl,
|
||||
onActiveItemChanged: (item) => onActiveItem(item, baseUrl),
|
||||
onItemTap: (item) => goTmdbDetail(
|
||||
context,
|
||||
tmdbId: item.id,
|
||||
mediaType: 'tv',
|
||||
seed: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscoverSection extends ConsumerWidget {
|
||||
final String title;
|
||||
final ProviderListenable<AsyncValue<TmdbRecommendationsRes?>> provider;
|
||||
final String imageBaseUrl;
|
||||
|
||||
|
||||
final String? fallbackMediaType;
|
||||
|
||||
const _DiscoverSection({
|
||||
required this.title,
|
||||
required this.provider,
|
||||
required this.imageBaseUrl,
|
||||
this.fallbackMediaType,
|
||||
});
|
||||
|
||||
Widget _sectionTitle() => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 4),
|
||||
child: SectionHeader(label: title, labelColor: Colors.white),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncData = ref.watch(provider);
|
||||
|
||||
return asyncData.when(
|
||||
loading: () => SkeletonMediaRow(title: title, titleColor: Colors.white),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (res) {
|
||||
if (res == null) return const SizedBox.shrink();
|
||||
|
||||
if (res.results.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(),
|
||||
const EmptyState(
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: '暂无内容',
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final items = res.results.length > 20
|
||||
? res.results.sublist(0, 20)
|
||||
: res.results;
|
||||
|
||||
final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
|
||||
final cardWidth = MediaRowMetrics.cardWidth(
|
||||
MediaCardVariant.poster,
|
||||
compact: compact,
|
||||
);
|
||||
final rowHeight = cardWidth / 0.66 + 46.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(),
|
||||
SizedBox(
|
||||
height: rowHeight,
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, controller) => ListView.separated(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 14),
|
||||
itemBuilder: (_, i) {
|
||||
final item = items[i];
|
||||
final type = item.mediaType ?? fallbackMediaType;
|
||||
final tappable = type == 'movie' || type == 'tv';
|
||||
return TmdbPosterCard(
|
||||
key: ValueKey<String>('$type:${item.id}'),
|
||||
title: item.title,
|
||||
posterPath: item.posterPath,
|
||||
voteAverage: item.voteAverage,
|
||||
mediaTypeLabel: _mediaTypeLabel(item.mediaType),
|
||||
imageBaseUrl: imageBaseUrl,
|
||||
width: cardWidth,
|
||||
textColor: Colors.white,
|
||||
onTap: tappable
|
||||
? () => goTmdbDetail(
|
||||
context,
|
||||
tmdbId: item.id,
|
||||
mediaType: type!,
|
||||
seed: item,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String? _mediaTypeLabel(String? type) => mediaTypeLabel(type);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import '../../core/contracts/trakt/trakt_playback_entry.dart';
|
||||
|
||||
|
||||
class TraktContinueWatchingItem {
|
||||
|
||||
final int tmdbId;
|
||||
|
||||
|
||||
final String mediaType;
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
|
||||
final double progress;
|
||||
|
||||
final String? backdropUrl;
|
||||
|
||||
const TraktContinueWatchingItem({
|
||||
required this.tmdbId,
|
||||
required this.mediaType,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.progress,
|
||||
required this.backdropUrl,
|
||||
});
|
||||
|
||||
|
||||
static TraktContinueWatchingItem? fromEntry(
|
||||
TraktPlaybackEntry e, {
|
||||
String? backdropUrl,
|
||||
}) {
|
||||
final progress = (e.progress / 100.0).clamp(0.0, 1.0).toDouble();
|
||||
|
||||
if (e.type == 'movie') {
|
||||
final tmdb = e.movieIds?.tmdb;
|
||||
if (tmdb == null || tmdb <= 0) return null;
|
||||
return TraktContinueWatchingItem(
|
||||
tmdbId: tmdb,
|
||||
mediaType: 'movie',
|
||||
title: e.movieTitle ?? '',
|
||||
subtitle: e.movieYear?.toString(),
|
||||
progress: progress,
|
||||
backdropUrl: backdropUrl,
|
||||
);
|
||||
}
|
||||
|
||||
final tmdb = e.showIds?.tmdb;
|
||||
if (tmdb == null || tmdb <= 0) return null;
|
||||
return TraktContinueWatchingItem(
|
||||
tmdbId: tmdb,
|
||||
mediaType: 'tv',
|
||||
title: e.showTitle ?? '',
|
||||
subtitle: _episodeSubtitle(
|
||||
e.episodeSeason,
|
||||
e.episodeNumber,
|
||||
e.episodeTitle,
|
||||
),
|
||||
progress: progress,
|
||||
backdropUrl: backdropUrl,
|
||||
);
|
||||
}
|
||||
|
||||
TraktContinueWatchingItem withBackdrop(String? url) {
|
||||
return TraktContinueWatchingItem(
|
||||
tmdbId: tmdbId,
|
||||
mediaType: mediaType,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
progress: progress,
|
||||
backdropUrl: url,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
TraktContinueWatchingItem withTitle(String title) {
|
||||
return TraktContinueWatchingItem(
|
||||
tmdbId: tmdbId,
|
||||
mediaType: mediaType,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
progress: progress,
|
||||
backdropUrl: backdropUrl,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _episodeSubtitle(int? season, int? number, String? title) {
|
||||
final String base;
|
||||
if (season != null && season > 0 && number != null) {
|
||||
base = '第$season季 第$number集';
|
||||
} else if (number != null) {
|
||||
base = '第$number集';
|
||||
} else {
|
||||
base = '';
|
||||
}
|
||||
final t = title?.trim();
|
||||
if (t != null && t.isNotEmpty) {
|
||||
return base.isEmpty ? t : '$base · $t';
|
||||
}
|
||||
return base.isEmpty ? null : base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/tmdb.dart';
|
||||
import '../../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../../shared/widgets/banner_carousel_mixin.dart';
|
||||
import '../../../shared/widgets/banner_carousel_scaffold.dart';
|
||||
import '../discover_helpers.dart';
|
||||
|
||||
|
||||
class TmdbBannerCarousel extends StatefulWidget {
|
||||
final List<TmdbRecommendation> items;
|
||||
final String imageBaseUrl;
|
||||
final ValueChanged<TmdbRecommendation> onItemTap;
|
||||
final ValueChanged<TmdbRecommendation>? onActiveItemChanged;
|
||||
|
||||
const TmdbBannerCarousel({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.imageBaseUrl,
|
||||
required this.onItemTap,
|
||||
this.onActiveItemChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TmdbBannerCarousel> createState() => _TmdbBannerCarouselState();
|
||||
}
|
||||
|
||||
class _TmdbBannerCarouselState extends State<TmdbBannerCarousel>
|
||||
with WidgetsBindingObserver, BannerCarouselMixin<TmdbBannerCarousel> {
|
||||
@override
|
||||
int get itemCount => widget.items.length;
|
||||
|
||||
@override
|
||||
bool get isActive => true;
|
||||
|
||||
@override
|
||||
void emitActiveItem() {
|
||||
if (widget.items.isEmpty) return;
|
||||
widget.onActiveItemChanged?.call(widget.items[currentIndex]);
|
||||
}
|
||||
|
||||
@override
|
||||
void precacheAdjacentSlides() {
|
||||
if (!mounted || widget.items.length <= 1) return;
|
||||
final next = (currentIndex + 1) % widget.items.length;
|
||||
final nextUrl = TmdbImageUrl.backdrop(
|
||||
widget.imageBaseUrl,
|
||||
widget.items[next].backdropPath,
|
||||
);
|
||||
if (nextUrl == null) return;
|
||||
final cacheWidth = bannerCacheWidth();
|
||||
unawaited(
|
||||
precacheImage(
|
||||
ResizeImage.resizeIfNeeded(
|
||||
cacheWidth,
|
||||
null,
|
||||
CachedNetworkImageProvider(nextUrl),
|
||||
),
|
||||
context,
|
||||
onError: (_, _) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TmdbBannerCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.items != widget.items) {
|
||||
resetCarouselItems();
|
||||
}
|
||||
}
|
||||
|
||||
BannerSlideData _slideDataFor(TmdbRecommendation item) {
|
||||
final typeLabel = _mediaTypeLabel(item.mediaType);
|
||||
return BannerSlideData(
|
||||
imageUrl: TmdbImageUrl.backdrop(widget.imageBaseUrl, item.backdropPath),
|
||||
title: item.title,
|
||||
overview: item.overview,
|
||||
rating: (item.voteAverage != null && item.voteAverage! > 0)
|
||||
? item.voteAverage!.toStringAsFixed(1)
|
||||
: null,
|
||||
primaryMetaLabels: [if (typeLabel != null) typeLabel],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BannerCarouselScaffold(
|
||||
controller: this,
|
||||
slides: [for (final item in widget.items) _slideDataFor(item)],
|
||||
onSlideTap: (index) => widget.onItemTap(widget.items[index]),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _mediaTypeLabel(String? type) => mediaTypeLabel(type);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/di_providers.dart';
|
||||
import '../../../shared/utils/media_nav.dart';
|
||||
import '../../../shared/widgets/card_progress_bar.dart';
|
||||
import '../../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../../shared/widgets/media_row_metrics.dart';
|
||||
import '../../../shared/widgets/media_poster_card.dart';
|
||||
import '../../../shared/widgets/section_header.dart';
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
import '../../../shared/widgets/skeleton_media_row.dart';
|
||||
import '../trakt_continue_watching_item.dart';
|
||||
|
||||
|
||||
class TraktContinueWatchingRow extends ConsumerWidget {
|
||||
const TraktContinueWatchingRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncItems = ref.watch(traktContinueWatchingProvider);
|
||||
final compact = MediaRowMetrics.isCompact(context);
|
||||
final rowHeight = MediaRowMetrics.rowHeight(
|
||||
MediaCardVariant.landscape,
|
||||
compact: compact,
|
||||
);
|
||||
final cardWidth = MediaRowMetrics.cardWidth(
|
||||
MediaCardVariant.landscape,
|
||||
compact: compact,
|
||||
);
|
||||
|
||||
return asyncItems.when(
|
||||
loading: () => const SkeletonMediaRow(
|
||||
title: '继续观看',
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
titleColor: Colors.white,
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 16, 24, 4),
|
||||
child: SectionHeader(label: '继续观看', labelColor: Colors.white),
|
||||
),
|
||||
SizedBox(
|
||||
height: rowHeight,
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, controller) => ListView.separated(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 14),
|
||||
itemBuilder: (_, i) {
|
||||
final item = items[i];
|
||||
return _TraktContinueCard(
|
||||
key: ValueKey<String>('${item.mediaType}:${item.tmdbId}'),
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
onTap: () => goTmdbDetail(
|
||||
context,
|
||||
tmdbId: item.tmdbId,
|
||||
mediaType: item.mediaType,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TraktContinueCard extends StatelessWidget {
|
||||
final TraktContinueWatchingItem item;
|
||||
final double width;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TraktContinueCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.width,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Stack(
|
||||
children: [
|
||||
SmartImage(
|
||||
url: item.backdropUrl,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 8,
|
||||
fallbackIcon: Icons.movie_outlined,
|
||||
),
|
||||
if (item.progress > 0)
|
||||
CardProgressBar(progress: item.progress),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (item.subtitle != null)
|
||||
Text(
|
||||
item.subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../core/domain/ports/emby_gateway.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../aggregated/aggregated_grouping.dart';
|
||||
import '../aggregated/widgets/aggregated_grouped_grid.dart';
|
||||
import '../aggregated/widgets/aggregated_media_card.dart';
|
||||
import '../aggregated/widgets/server_filter_bar.dart';
|
||||
|
||||
const _favoriteTypeFilters = <_FavoriteTypeFilter>[
|
||||
_FavoriteTypeFilter(includeItemTypes: 'Movie,Series,Episode', label: '全部'),
|
||||
_FavoriteTypeFilter(includeItemTypes: 'Movie', label: '电影'),
|
||||
_FavoriteTypeFilter(includeItemTypes: 'Series', label: '剧集'),
|
||||
_FavoriteTypeFilter(includeItemTypes: 'Episode', label: '单集'),
|
||||
];
|
||||
|
||||
class AggregatedFavoritesPage extends ConsumerStatefulWidget {
|
||||
|
||||
final bool embedded;
|
||||
|
||||
const AggregatedFavoritesPage({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AggregatedFavoritesPage> createState() =>
|
||||
_AggregatedFavoritesPageState();
|
||||
}
|
||||
|
||||
class _AggregatedFavoritesPageState
|
||||
extends ConsumerState<AggregatedFavoritesPage> {
|
||||
StreamSubscription<GlobalSearchServerResult>? _subscription;
|
||||
|
||||
_FavoriteTypeFilter _selectedFilter = _favoriteTypeFilters.first;
|
||||
List<GlobalSearchServerResult> _serverResults = [];
|
||||
bool _loading = false;
|
||||
bool _switching = false;
|
||||
final Map<String, bool> _favoriteLoadingMap = {};
|
||||
|
||||
final Set<String> _collapsed = {};
|
||||
String? _serverFilter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_serverResults = [];
|
||||
});
|
||||
|
||||
try {
|
||||
final uc = await ref.read(aggregatedFavoritesUseCaseProvider.future);
|
||||
final stream = uc.executeStream(
|
||||
includeItemTypes: _selectedFilter.includeItemTypes,
|
||||
);
|
||||
|
||||
_subscription = stream.listen(
|
||||
(result) {
|
||||
if (mounted) {
|
||||
setState(() => _serverResults = [..._serverResults, result]);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
onError: (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onItemTap(
|
||||
BuildContext context,
|
||||
GlobalSearchServerResult serverResult,
|
||||
EmbyRawItem item,
|
||||
) async {
|
||||
if (_switching) return;
|
||||
setState(() => _switching = true);
|
||||
try {
|
||||
await goMediaDetailOnServer(
|
||||
context,
|
||||
ref,
|
||||
serverId: serverResult.serverId,
|
||||
item: item,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _switching = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeFavorite(
|
||||
GlobalSearchServerResult serverResult,
|
||||
EmbyRawItem item,
|
||||
) async {
|
||||
if (_favoriteLoadingMap[item.Id] == true) return;
|
||||
setState(() => _favoriteLoadingMap[item.Id] = true);
|
||||
try {
|
||||
final gateway = await ref.read(embyGatewayProvider.future);
|
||||
final ctx = AuthedRequestContext(
|
||||
baseUrl: serverResult.serverUrl,
|
||||
token: serverResult.token,
|
||||
userId: serverResult.userId,
|
||||
);
|
||||
await gateway.setFavoriteStatus(
|
||||
ctx: ctx,
|
||||
itemId: item.Id,
|
||||
isFavorite: false,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_serverResults = _serverResults
|
||||
.map((sr) {
|
||||
if (sr.serverId != serverResult.serverId) return sr;
|
||||
final filtered = sr.items
|
||||
.where((i) => i.Id != item.Id)
|
||||
.toList();
|
||||
return GlobalSearchServerResult(
|
||||
serverId: sr.serverId,
|
||||
serverName: sr.serverName,
|
||||
serverUrl: sr.serverUrl,
|
||||
userId: sr.userId,
|
||||
token: sr.token,
|
||||
items: filtered,
|
||||
);
|
||||
})
|
||||
.where((sr) => sr.items.isNotEmpty)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _favoriteLoadingMap[item.Id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<FItem> _cardMenuItems(
|
||||
GlobalSearchServerResult sr,
|
||||
EmbyRawItem item,
|
||||
) => [
|
||||
FItem(
|
||||
prefix: const Icon(Icons.favorite, size: 16),
|
||||
enabled: !(_favoriteLoadingMap[item.Id] ?? false),
|
||||
title: const Text('取消收藏'),
|
||||
onPress: () => _removeFavorite(sr, item),
|
||||
),
|
||||
];
|
||||
|
||||
void _changeType(_FavoriteTypeFilter filter) {
|
||||
if (_selectedFilter == filter) return;
|
||||
setState(() => _selectedFilter = filter);
|
||||
_load();
|
||||
}
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (!_collapsed.remove(key)) _collapsed.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(sessionProvider, (previous, next) {
|
||||
final prevIds =
|
||||
previous?.value?.sessions.map((s) => s.serverId).toSet() ??
|
||||
<String>{};
|
||||
final nextIds =
|
||||
next.value?.sessions.map((s) => s.serverId).toSet() ?? <String>{};
|
||||
if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) {
|
||||
return;
|
||||
}
|
||||
if (_serverFilter != null && !nextIds.contains(_serverFilter)) {
|
||||
setState(() => _serverFilter = null);
|
||||
}
|
||||
_load();
|
||||
});
|
||||
|
||||
if (widget.embedded) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverOverlapInjector(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildTypeFilterRow()),
|
||||
..._buildContentSlivers(context),
|
||||
],
|
||||
),
|
||||
if (_switching) _switchingOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
title: '我的收藏',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loading ? null : _load,
|
||||
icon: _loading
|
||||
? const AppLoadingRing(size: 16)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildTypeFilterRow()),
|
||||
..._buildContentSlivers(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_switching) _switchingOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchingOverlay() => const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x44000000),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildTypeFilterRow() => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _favoriteTypeFilters.map(_buildTypeButton).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildTypeButton(_FavoriteTypeFilter filter) {
|
||||
final selected = _selectedFilter == filter;
|
||||
final child = Text(filter.label);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FButton(
|
||||
variant: selected ? FButtonVariant.primary : FButtonVariant.ghost,
|
||||
onPress: () => _changeType(filter),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildContentSlivers(BuildContext context) {
|
||||
if (_serverResults.isEmpty && !_loading) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(icon: Icons.favorite_border, title: '暂无收藏内容'),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_serverResults.isEmpty && _loading) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final entries = flatten(
|
||||
_serverResults,
|
||||
serverFilter: _serverFilter,
|
||||
deduplicateAcrossServers: true,
|
||||
);
|
||||
final groups = groupEntries(entries, GroupAxis.mediaType, now);
|
||||
final servers = _serverResults
|
||||
.map((sr) => (id: sr.serverId, name: sr.serverName))
|
||||
.toList(growable: false);
|
||||
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: ServerFilterBar(
|
||||
servers: servers,
|
||||
selected: _serverFilter,
|
||||
onSelect: (v) => setState(() => _serverFilter = v),
|
||||
),
|
||||
),
|
||||
...buildAggregatedGridSlivers(
|
||||
groups: groups,
|
||||
collapsed: _collapsed,
|
||||
onToggle: _toggle,
|
||||
cardBuilder: (entry) => _card(context, entry),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context, AggregatedEntry entry) {
|
||||
final sr = entry.source;
|
||||
final item = entry.item;
|
||||
final episode = formatEpisodeNumber(item.IndexNumber);
|
||||
final subtitle = item.Type == 'Episode' && episode != null
|
||||
? '${item.SeriesName ?? ''} $episode'.trim()
|
||||
: (item.ProductionYear != null ? '${item.ProductionYear}' : null);
|
||||
|
||||
return AggregatedMediaCard(
|
||||
item: item,
|
||||
imageUrl: EmbyImageUrl.landscapeCard(
|
||||
baseUrl: sr.serverUrl,
|
||||
token: sr.token,
|
||||
item: item,
|
||||
maxWidth: 400,
|
||||
),
|
||||
imageHeaders: buildEmbyImageHeaders(
|
||||
ref,
|
||||
token: sr.token,
|
||||
userId: sr.userId,
|
||||
),
|
||||
title: item.SeriesName ?? item.Name,
|
||||
subtitle: subtitle,
|
||||
serverName: sr.serverName,
|
||||
showProgress: false,
|
||||
onTap: () => _onItemTap(context, sr, item),
|
||||
contextMenuItems: _cardMenuItems(sr, item),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteTypeFilter {
|
||||
final String includeItemTypes;
|
||||
final String label;
|
||||
|
||||
const _FavoriteTypeFilter({
|
||||
required this.includeItemTypes,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/tmdb_settings_provider.dart';
|
||||
import '../../shared/mappers/tmdb_image_url.dart';
|
||||
import '../../shared/utils/format_utils.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/time_bucket.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/utils/tmdb_key_utils.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../aggregated/aggregated_grouping.dart';
|
||||
import '../aggregated/widgets/aggregated_grouped_grid.dart';
|
||||
import '../aggregated/widgets/aggregated_media_card.dart';
|
||||
import '../aggregated/widgets/server_filter_bar.dart';
|
||||
import 'widgets/history_source_picker.dart';
|
||||
|
||||
class HistoryPage extends ConsumerStatefulWidget {
|
||||
|
||||
final bool embedded;
|
||||
|
||||
const HistoryPage({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<HistoryPage> createState() => _HistoryPageState();
|
||||
}
|
||||
|
||||
class _HistoryPageState extends ConsumerState<HistoryPage> {
|
||||
StreamSubscription<GlobalSearchServerResult>? _subscription;
|
||||
|
||||
List<GlobalSearchServerResult> _serverResults = [];
|
||||
bool _loading = false;
|
||||
bool _switching = false;
|
||||
|
||||
final Set<String> _collapsed = {};
|
||||
String? _serverFilter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_serverResults = [];
|
||||
});
|
||||
|
||||
try {
|
||||
final uc = await ref.read(aggregatedResumeUseCaseProvider.future);
|
||||
final stream = uc.executeStream();
|
||||
|
||||
_subscription = stream.listen(
|
||||
(result) {
|
||||
if (mounted) {
|
||||
setState(() => _serverResults = [..._serverResults, result]);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
onError: (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onItemTap(
|
||||
BuildContext context,
|
||||
GlobalSearchServerResult serverResult,
|
||||
EmbyRawItem item,
|
||||
) async {
|
||||
if (_switching) return;
|
||||
setState(() => _switching = true);
|
||||
try {
|
||||
await goMediaDetailOnServer(
|
||||
context,
|
||||
ref,
|
||||
serverId: serverResult.serverId,
|
||||
item: item,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _switching = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onServerSourcesTap(
|
||||
BuildContext context,
|
||||
AggregatedEntry entry,
|
||||
DateTime now,
|
||||
) async {
|
||||
if (_switching || entry.sources.length < 2) return;
|
||||
final selectedSource = await showHistorySourcePicker(
|
||||
context,
|
||||
entry: entry,
|
||||
now: now,
|
||||
);
|
||||
if (selectedSource == null || !mounted || !context.mounted) return;
|
||||
await _onItemTap(context, selectedSource.source, selectedSource.item);
|
||||
}
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (!_collapsed.remove(key)) _collapsed.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(sessionProvider, (previous, next) {
|
||||
final prevIds =
|
||||
previous?.value?.sessions.map((s) => s.serverId).toSet() ??
|
||||
<String>{};
|
||||
final nextIds =
|
||||
next.value?.sessions.map((s) => s.serverId).toSet() ?? <String>{};
|
||||
if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) {
|
||||
return;
|
||||
}
|
||||
if (_serverFilter != null && !nextIds.contains(_serverFilter)) {
|
||||
setState(() => _serverFilter = null);
|
||||
}
|
||||
_load();
|
||||
});
|
||||
|
||||
if (widget.embedded) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverOverlapInjector(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context,
|
||||
),
|
||||
),
|
||||
..._buildContentSlivers(context),
|
||||
],
|
||||
),
|
||||
if (_switching) _switchingOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
title: '播放记录',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loading ? null : _load,
|
||||
icon: _loading
|
||||
? const AppLoadingRing(size: 16)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
..._buildContentSlivers(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_switching) _switchingOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchingOverlay() => const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x44000000),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _buildContentSlivers(BuildContext context) {
|
||||
if (_serverResults.isEmpty && !_loading) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(icon: Icons.play_circle_outline, title: '暂无播放记录'),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_serverResults.isEmpty && _loading) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final entries = flatten(
|
||||
_serverResults,
|
||||
serverFilter: _serverFilter,
|
||||
deduplicateAcrossServers: true,
|
||||
);
|
||||
final groups = groupEntries(entries, GroupAxis.watchedTime, now);
|
||||
final servers = _serverResults
|
||||
.map((sr) => (id: sr.serverId, name: sr.serverName))
|
||||
.toList(growable: false);
|
||||
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ServerFilterBar(
|
||||
servers: servers,
|
||||
selected: _serverFilter,
|
||||
onSelect: (v) => setState(() => _serverFilter = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
...buildAggregatedGridSlivers(
|
||||
groups: groups,
|
||||
collapsed: _collapsed,
|
||||
onToggle: _toggle,
|
||||
cardBuilder: (entry) => _card(context, entry, now),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context, AggregatedEntry entry, DateTime now) {
|
||||
return _TmdbAwareHistoryCard(
|
||||
entry: entry,
|
||||
now: now,
|
||||
imageHeaders: buildEmbyImageHeaders(
|
||||
ref,
|
||||
token: entry.source.token,
|
||||
userId: entry.source.userId,
|
||||
),
|
||||
onTap: () => _onItemTap(context, entry.source, entry.item),
|
||||
onServerSourcesTap: entry.sources.length > 1
|
||||
? () => unawaited(_onServerSourcesTap(context, entry, now))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TmdbAwareHistoryCard extends ConsumerWidget {
|
||||
final AggregatedEntry entry;
|
||||
final DateTime now;
|
||||
final Map<String, String> imageHeaders;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onServerSourcesTap;
|
||||
|
||||
const _TmdbAwareHistoryCard({
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.imageHeaders,
|
||||
required this.onTap,
|
||||
this.onServerSourcesTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sr = entry.source;
|
||||
final item = entry.item;
|
||||
final episode = formatEpisodeNumber(item.IndexNumber);
|
||||
final subtitle = episode != null
|
||||
? '$episode · ${item.Name}'
|
||||
: (item.ProductionYear != null ? '${item.ProductionYear}' : null);
|
||||
|
||||
final key = tmdbMediaKeyFrom(item);
|
||||
String? networkName;
|
||||
String? networkLogoUrl;
|
||||
if (key != null) {
|
||||
final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value;
|
||||
final network = enriched?.networks.firstOrNull;
|
||||
if (network != null) {
|
||||
networkName = network.name;
|
||||
final imageBase = ref
|
||||
.watch(tmdbSettingsProvider)
|
||||
.value
|
||||
?.effectiveImageBaseUrl;
|
||||
if (imageBase != null) {
|
||||
networkLogoUrl = TmdbImageUrl.logo(
|
||||
imageBase,
|
||||
network.logoPath,
|
||||
size: 'w92',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AggregatedMediaCard(
|
||||
item: item,
|
||||
imageUrl: EmbyImageUrl.landscapeCard(
|
||||
baseUrl: sr.serverUrl,
|
||||
token: sr.token,
|
||||
item: item,
|
||||
maxWidth: 400,
|
||||
),
|
||||
imageHeaders: imageHeaders,
|
||||
title: item.SeriesName ?? item.Name,
|
||||
subtitle: subtitle,
|
||||
serverName: sr.serverName,
|
||||
serverSourceCount: entry.sources.length,
|
||||
networkName: networkName,
|
||||
networkLogoUrl: networkLogoUrl,
|
||||
timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.watchedTime), now),
|
||||
showProgress: true,
|
||||
onTap: onTap,
|
||||
onServerSourcesTap: onServerSourcesTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/emby_ticks.dart';
|
||||
import '../../../shared/utils/time_bucket.dart';
|
||||
import '../../../shared/widgets/adaptive_modal.dart';
|
||||
import '../../aggregated/aggregated_grouping.dart';
|
||||
|
||||
|
||||
Future<AggregatedEntrySource?> showHistorySourcePicker(
|
||||
BuildContext context, {
|
||||
required AggregatedEntry entry,
|
||||
DateTime? now,
|
||||
}) {
|
||||
return showAdaptiveModal<AggregatedEntrySource>(
|
||||
context: context,
|
||||
maxWidth: 520,
|
||||
maxHeight: 640,
|
||||
builder: (modalContext) => HistorySourcePickerContent(
|
||||
entry: entry,
|
||||
now: now ?? DateTime.now(),
|
||||
onSourceSelected: (source) {
|
||||
Navigator.of(modalContext).pop(source);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class HistorySourcePickerContent extends StatelessWidget {
|
||||
final AggregatedEntry entry;
|
||||
final DateTime now;
|
||||
final ValueChanged<AggregatedEntrySource> onSourceSelected;
|
||||
|
||||
const HistorySourcePickerContent({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.now,
|
||||
required this.onSourceSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final desiredHeight = 104.0 + (entry.sources.length * 92.0);
|
||||
final contentHeight = math.min(desiredHeight, 560.0);
|
||||
|
||||
return SizedBox(
|
||||
height: contentHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择服务器',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
entry.item.SeriesName ?? entry.item.Name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '关闭',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: context.appTokens.separatorColor),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: entry.sources.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 2),
|
||||
itemBuilder: (context, index) {
|
||||
final source = entry.sources[index];
|
||||
return _HistorySourceRow(
|
||||
source: source,
|
||||
now: now,
|
||||
isPrimary: index == 0,
|
||||
onTap: () => onSourceSelected(source),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistorySourceRow extends StatelessWidget {
|
||||
final AggregatedEntrySource source;
|
||||
final DateTime now;
|
||||
final bool isPrimary;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _HistorySourceRow({
|
||||
required this.source,
|
||||
required this.now,
|
||||
required this.isPrimary,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final progress = _HistorySourceProgress.fromItem(source.item);
|
||||
final lastPlayedLabel =
|
||||
relativeTimeLabel(source.dateFor(GroupAxis.watchedTime), now) ??
|
||||
'未记录播放时间';
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
source.source.serverName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isPrimary) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'最近播放',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.value ?? 0,
|
||||
minHeight: 4,
|
||||
backgroundColor: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.08,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.label} · $lastPlayedLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 20,
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistorySourceProgress {
|
||||
final double? value;
|
||||
final String label;
|
||||
|
||||
const _HistorySourceProgress({required this.value, required this.label});
|
||||
|
||||
factory _HistorySourceProgress.fromItem(EmbyRawItem item) {
|
||||
final userData = item.UserData;
|
||||
if (userData?.Played == true) {
|
||||
return const _HistorySourceProgress(value: 1, label: '已看完');
|
||||
}
|
||||
|
||||
final positionTicks = userData?.PlaybackPositionTicks;
|
||||
final runtimeTicks = item.RunTimeTicks;
|
||||
if (positionTicks == null || runtimeTicks == null || runtimeTicks <= 0) {
|
||||
return const _HistorySourceProgress(value: null, label: '进度未知');
|
||||
}
|
||||
|
||||
final progress = (positionTicks / runtimeTicks).clamp(0.0, 1.0);
|
||||
final watchedPercent = (progress * 100).round();
|
||||
final remainingTicks = runtimeTicks - positionTicks;
|
||||
if (remainingTicks <= 0) {
|
||||
return const _HistorySourceProgress(value: 1, label: '已看完');
|
||||
}
|
||||
|
||||
final remainingMinutes = (remainingTicks / kTicksPerMinute).ceil();
|
||||
return _HistorySourceProgress(
|
||||
value: progress,
|
||||
label: '已看 $watchedPercent% · 剩余 $remainingMinutes 分钟',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/auth.dart';
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/emby_headers_provider.dart';
|
||||
import '../../providers/home_banner_provider.dart';
|
||||
import '../../providers/home_page_mode_provider.dart';
|
||||
import '../../providers/library_overview_provider.dart';
|
||||
import '../../providers/playback_active_provider.dart';
|
||||
import '../../providers/server_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/shell_backdrop_provider.dart';
|
||||
import '../../providers/tmdb_prefetch_provider.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/color_utils.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/add_server_modal.dart';
|
||||
import '../../shared/widgets/app_dialog.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/error_media_row.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/widgets/frosted_panel.dart';
|
||||
import '../../shared/widgets/latest_media_row.dart';
|
||||
import '../../shared/widgets/login_form.dart';
|
||||
import '../../shared/widgets/media_poster_card.dart';
|
||||
import '../../shared/constants/breakpoints.dart';
|
||||
import '../../shared/widgets/scroll_fade_header.dart';
|
||||
import '../../shared/widgets/skeleton_media_row.dart';
|
||||
import '../discover/discover_page.dart';
|
||||
import 'widgets/banner_carousel.dart';
|
||||
import 'widgets/server_dropdown_button.dart';
|
||||
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen<AuthedSession?>(activeSessionProvider, (previous, next) {
|
||||
if (previous != null && next == null) {
|
||||
ref.read(shellBackdropProvider.notifier).clear();
|
||||
}
|
||||
});
|
||||
|
||||
final sessionAsync = ref.watch(sessionProvider);
|
||||
if (sessionAsync.isLoading && !sessionAsync.hasValue) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const _UnauthenticatedHome();
|
||||
return _AuthenticatedHome(session: session);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnauthenticatedHome extends ConsumerWidget {
|
||||
const _UnauthenticatedHome();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final servers = ref.watch(serverListProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: servers.when(
|
||||
data: (list) {
|
||||
final availableList = list.where((s) => !s.paused).toList();
|
||||
|
||||
if (list.isEmpty || availableList.isEmpty) {
|
||||
return Center(
|
||||
child: EmptyState(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: list.isEmpty ? '尚未添加 Emby 服务器' : '所有服务器已暂停',
|
||||
message: list.isEmpty
|
||||
? '添加第一个服务器,开始构建你的影库。'
|
||||
: '前往「服务器」页恢复使用,或添加新服务器。',
|
||||
action: FButton(
|
||||
variant: FButtonVariant.primary,
|
||||
onPress: () => showAddServerModal(context),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('添加服务器'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
child: FrostedPanel(
|
||||
radius: 32,
|
||||
padding: const EdgeInsets.all(28),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withValues(alpha: 0.72),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.22),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'选择服务器登录',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'所有内容都会以当前服务器为中心进行同步与推荐。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
...availableList.map(
|
||||
(server) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: FCard(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.dns_outlined),
|
||||
title: Text(server.name),
|
||||
subtitle: Text(server.baseUrl),
|
||||
onTap: () async {
|
||||
await showAppRawDialog<void>(
|
||||
context,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 460,
|
||||
),
|
||||
builder: (_) => Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: LoginForm(
|
||||
server: server,
|
||||
onBack: () => Navigator.pop(context),
|
||||
onSuccess: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => showAddServerModal(context),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('添加新服务器'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: AppLoadingRing()),
|
||||
error: (error, _) => AppErrorState(
|
||||
message: formatUserError(error, fallback: '登录态加载失败'),
|
||||
onRetry: () => ref.invalidate(sessionProvider),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthenticatedHome extends ConsumerStatefulWidget {
|
||||
final AuthedSession session;
|
||||
|
||||
const _AuthenticatedHome({required this.session});
|
||||
|
||||
@override
|
||||
ConsumerState<_AuthenticatedHome> createState() => _AuthenticatedHomeState();
|
||||
}
|
||||
|
||||
class _AuthenticatedHomeState extends ConsumerState<_AuthenticatedHome> {
|
||||
final Map<String, bool> _favoriteMap = {};
|
||||
final Map<String, bool> _playedMap = {};
|
||||
final Map<String, bool> _favoriteLoadingMap = {};
|
||||
final Map<String, bool> _playedLoadingMap = {};
|
||||
final Map<String, bool> _hideResumeLoadingMap = {};
|
||||
final Set<String> _hiddenResumeIds = {};
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ValueNotifier<double> _scrollProgress = ValueNotifier<double>(0.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
_scrollController.dispose();
|
||||
_scrollProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
const threshold = 120.0;
|
||||
final progress = (_scrollController.offset / threshold).clamp(0.0, 1.0);
|
||||
_scrollProgress.value = progress;
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(EmbyRawItem item) async {
|
||||
if (_favoriteLoadingMap[item.Id] == true) return;
|
||||
final next = !(_favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false);
|
||||
setState(() => _favoriteLoadingMap[item.Id] = true);
|
||||
try {
|
||||
final useCase = await ref.read(setFavoriteUseCaseProvider.future);
|
||||
final result = await useCase.execute(
|
||||
FavoriteSetReq(itemId: item.Id, isFavorite: next),
|
||||
);
|
||||
setState(() => _favoriteMap[item.Id] = result.isFavorite);
|
||||
} finally {
|
||||
setState(() => _favoriteLoadingMap[item.Id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePlayed(EmbyRawItem item) async {
|
||||
if (_playedLoadingMap[item.Id] == true) return;
|
||||
final next = !(_playedMap[item.Id] ?? item.UserData?.Played ?? false);
|
||||
setState(() => _playedLoadingMap[item.Id] = true);
|
||||
try {
|
||||
final useCase = await ref.read(setPlayedUseCaseProvider.future);
|
||||
final result = await useCase.execute(
|
||||
PlayedSetReq(itemId: item.Id, played: next),
|
||||
);
|
||||
setState(() => _playedMap[item.Id] = result.played);
|
||||
unawaited(_syncTraktPlayedChange(item, result.played));
|
||||
} finally {
|
||||
setState(() => _playedLoadingMap[item.Id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTraktPlayedChange(EmbyRawItem item, bool played) async {
|
||||
try {
|
||||
final service = await ref.read(traktSyncServiceProvider.future);
|
||||
await service.syncEmbyPlayedChange(item: item, played: played);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _hideFromResume(EmbyRawItem item) async {
|
||||
if (_hideResumeLoadingMap[item.Id] == true) return;
|
||||
setState(() {
|
||||
_hideResumeLoadingMap[item.Id] = true;
|
||||
_hiddenResumeIds.add(item.Id);
|
||||
});
|
||||
try {
|
||||
final useCase = await ref.read(hideFromResumeUseCaseProvider.future);
|
||||
final result = await useCase.execute(
|
||||
HideFromResumeReq(itemId: item.Id, hide: true),
|
||||
);
|
||||
if (!result.hide) {
|
||||
setState(() => _hiddenResumeIds.remove(item.Id));
|
||||
}
|
||||
} catch (_) {
|
||||
setState(() => _hiddenResumeIds.remove(item.Id));
|
||||
} finally {
|
||||
setState(() => _hideResumeLoadingMap[item.Id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _resolveFavorite(EmbyRawItem item) {
|
||||
return _favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false;
|
||||
}
|
||||
|
||||
bool _resolvePlayed(EmbyRawItem item) {
|
||||
return _playedMap[item.Id] ?? item.UserData?.Played ?? false;
|
||||
}
|
||||
|
||||
Map<String, bool> _buildFavoriteMap(List<EmbyRawItem> items) {
|
||||
return {for (final item in items) item.Id: _resolveFavorite(item)};
|
||||
}
|
||||
|
||||
Map<String, bool> _buildPlayedMap(List<EmbyRawItem> items) {
|
||||
return {for (final item in items) item.Id: _resolvePlayed(item)};
|
||||
}
|
||||
|
||||
void _openMediaDetail(EmbyRawItem item) {
|
||||
goMediaDetail(context, item, ref: ref);
|
||||
}
|
||||
|
||||
EmbyRawItem? _lastBannerItem;
|
||||
|
||||
bool _isHomeForeground() {
|
||||
return ref.read(shellLocationProvider) == '/' &&
|
||||
!ref.read(detailPageActiveProvider);
|
||||
}
|
||||
|
||||
void _updateShellBackdrop(EmbyRawItem item) {
|
||||
_lastBannerItem = item;
|
||||
if (!_isHomeForeground()) return;
|
||||
|
||||
final backdropUrl = EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
);
|
||||
final fallbackId = (item.SeriesId?.isNotEmpty ?? false)
|
||||
? item.SeriesId!
|
||||
: item.Id;
|
||||
final fallbackUrls = <String>{
|
||||
EmbyImageUrl.fanart(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
itemId: fallbackId,
|
||||
),
|
||||
backdropUrl,
|
||||
}.toList();
|
||||
final accentColor = _accentColorFor(item.Id);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isHomeForeground()) return;
|
||||
ref
|
||||
.read(shellBackdropProvider.notifier)
|
||||
.show(
|
||||
ShellBackdropState(
|
||||
primaryImageUrl: backdropUrl,
|
||||
fallbackImageUrls: fallbackUrls,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearShellBackdropDeferred() {
|
||||
_lastBannerItem = null;
|
||||
if (ref.read(shellBackdropProvider) == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isHomeForeground()) return;
|
||||
ref.read(shellBackdropProvider.notifier).clear();
|
||||
});
|
||||
}
|
||||
|
||||
Color _accentColorFor(String seed) => accentColorForSeed(
|
||||
seed,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
|
||||
final mode = ref.watch(homePageModeProvider);
|
||||
if (isCompact && mode == HomePageMode.recommend) {
|
||||
return const DiscoverPage(embedInHome: true);
|
||||
}
|
||||
|
||||
final state = ref.watch(libraryOverviewProvider);
|
||||
final bannerAsync = ref.watch(homeBannerProvider);
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final homeForeground =
|
||||
ref.watch(shellLocationProvider) == '/' &&
|
||||
!ref.watch(detailPageActiveProvider);
|
||||
ref.watch(tmdbPrefetchProvider);
|
||||
|
||||
ref.listen<String>(shellLocationProvider, (_, loc) {
|
||||
if (loc != '/') return;
|
||||
if (ref.read(detailPageActiveProvider)) return;
|
||||
if (_lastBannerItem != null) {
|
||||
_updateShellBackdrop(_lastBannerItem!);
|
||||
} else {
|
||||
_clearShellBackdropDeferred();
|
||||
}
|
||||
});
|
||||
ref.listen<bool>(detailPageActiveProvider, (_, active) {
|
||||
if (active) return;
|
||||
if (ref.read(shellLocationProvider) != '/') return;
|
||||
ref.read(libraryOverviewProvider.notifier).softRefresh();
|
||||
if (_lastBannerItem != null) {
|
||||
_updateShellBackdrop(_lastBannerItem!);
|
||||
} else {
|
||||
_clearShellBackdropDeferred();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: state.when(
|
||||
data: (data) {
|
||||
if (data.libraries.isEmpty) {
|
||||
_clearShellBackdropDeferred();
|
||||
return const Center(
|
||||
child: EmptyState(
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: '没有可用的媒体库',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final resumeItems = data.resumeItems
|
||||
.where((item) => !_hiddenResumeIds.contains(item.Id))
|
||||
.toList();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: () => Future.wait([
|
||||
ref.read(libraryOverviewProvider.notifier).softRefresh(),
|
||||
ref.read(homeBannerProvider.notifier).softRefresh(),
|
||||
]),
|
||||
displacement: 72,
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.only(top: isCompact ? 0 : 60, bottom: 32),
|
||||
children: [
|
||||
bannerAsync.when(
|
||||
data: (bannerItems) {
|
||||
if (bannerItems.isEmpty) {
|
||||
_clearShellBackdropDeferred();
|
||||
return const SizedBox.shrink(
|
||||
key: ValueKey('home-banner-empty'),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
key: const ValueKey('home-banner'),
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: HomeBannerCarousel(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
items: bannerItems,
|
||||
imageHeaders: imageHeaders,
|
||||
onItemTap: _openMediaDetail,
|
||||
onActiveItemChanged: _updateShellBackdrop,
|
||||
active: homeForeground,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Padding(
|
||||
key: ValueKey('home-banner-loading'),
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: HomeBannerSkeleton(),
|
||||
),
|
||||
error: (_, _) {
|
||||
_clearShellBackdropDeferred();
|
||||
return const SizedBox.shrink(
|
||||
key: ValueKey('home-banner-error'),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (data.resumeLoading)
|
||||
const SkeletonMediaRow(
|
||||
key: ValueKey('home-resume-row'),
|
||||
title: '继续观看',
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
titleColor: Colors.white,
|
||||
)
|
||||
else if (data.resumeError != null)
|
||||
ErrorMediaRow(
|
||||
key: const ValueKey('home-resume-row'),
|
||||
title: '继续观看',
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
titleColor: Colors.white,
|
||||
onRetry: () => ref
|
||||
.read(libraryOverviewProvider.notifier)
|
||||
.retryResume(),
|
||||
)
|
||||
else if (resumeItems.isNotEmpty)
|
||||
LatestMediaRow(
|
||||
key: const ValueKey('home-resume-row'),
|
||||
title: '继续观看',
|
||||
items: resumeItems,
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
enableContextMenu: true,
|
||||
favoriteMap: _buildFavoriteMap(resumeItems),
|
||||
playedMap: _buildPlayedMap(resumeItems),
|
||||
favoriteLoadingMap: _favoriteLoadingMap,
|
||||
playedLoadingMap: _playedLoadingMap,
|
||||
onAddFavorite: _toggleFavorite,
|
||||
onMarkPlayed: _togglePlayed,
|
||||
onHideFromResume: _hideFromResume,
|
||||
hideFromResumeLoadingMap: _hideResumeLoadingMap,
|
||||
imageHeaders: imageHeaders,
|
||||
titleColor: Colors.white,
|
||||
imageUrlBuilder: (item) => EmbyImageUrl.landscapeCard(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr),
|
||||
),
|
||||
preloadImageUrlBuilder: (item) =>
|
||||
EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
),
|
||||
onItemTap: _openMediaDetail,
|
||||
),
|
||||
if (data.libraries.isNotEmpty)
|
||||
LatestMediaRow(
|
||||
key: const ValueKey('home-library-row'),
|
||||
title: '我的媒体列表',
|
||||
items: data.libraries
|
||||
.map(
|
||||
(library) => EmbyRawItem(
|
||||
Id: library.Id,
|
||||
Name: library.Name,
|
||||
PrimaryImageTag: library.ImageTags?['Primary'],
|
||||
ImageTags: library.ImageTags,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
cardVariant: MediaCardVariant.landscape,
|
||||
showHoverPlayIcon: false,
|
||||
imageHeaders: imageHeaders,
|
||||
titleColor: Colors.white,
|
||||
imageUrlBuilder: (item) => EmbyImageUrl.primary(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr),
|
||||
),
|
||||
onItemTap: (item) =>
|
||||
context.push('/library/${item.Id}'),
|
||||
),
|
||||
...data.libraries.expand((library) {
|
||||
final isLoading = data.loadingLibraryIds.contains(
|
||||
library.Id,
|
||||
);
|
||||
if (isLoading) {
|
||||
return [
|
||||
SkeletonMediaRow(
|
||||
key: ValueKey('home-latest-row-${library.Id}'),
|
||||
title: '${library.Name} · 最新更新',
|
||||
titleColor: Colors.white,
|
||||
),
|
||||
];
|
||||
}
|
||||
final error = data.latestErrorByLibrary[library.Id];
|
||||
if (error != null) {
|
||||
return [
|
||||
ErrorMediaRow(
|
||||
key: ValueKey('home-latest-row-${library.Id}'),
|
||||
title: '${library.Name} · 最新更新',
|
||||
titleColor: Colors.white,
|
||||
onRetry: () => ref
|
||||
.read(libraryOverviewProvider.notifier)
|
||||
.retryLatest(library.Id),
|
||||
),
|
||||
];
|
||||
}
|
||||
final items =
|
||||
data.latestByLibrary[library.Id] ??
|
||||
const <EmbyRawItem>[];
|
||||
if (items.isEmpty) return const <Widget>[];
|
||||
return [
|
||||
LatestMediaRow(
|
||||
key: ValueKey('home-latest-row-${library.Id}'),
|
||||
title: library.Name,
|
||||
subtitle: '最新更新',
|
||||
items: items,
|
||||
totalCount: data.totalCountByLibrary[library.Id],
|
||||
enableContextMenu: true,
|
||||
favoriteMap: _buildFavoriteMap(items),
|
||||
playedMap: _buildPlayedMap(items),
|
||||
favoriteLoadingMap: _favoriteLoadingMap,
|
||||
playedLoadingMap: _playedLoadingMap,
|
||||
onAddFavorite: _toggleFavorite,
|
||||
onMarkPlayed: _togglePlayed,
|
||||
imageHeaders: imageHeaders,
|
||||
titleColor: Colors.white,
|
||||
imageUrlBuilder: (item) => EmbyImageUrl.primary(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
maxWidth: EmbyImageUrl.targetPixelWidth(158, dpr),
|
||||
),
|
||||
preloadImageUrlBuilder: (item) =>
|
||||
EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.session.serverUrl,
|
||||
token: widget.session.token,
|
||||
item: item,
|
||||
),
|
||||
onItemTap: _openMediaDetail,
|
||||
onSeeAll: () =>
|
||||
context.push('/library/${library.Id}'),
|
||||
),
|
||||
];
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (MediaQuery.sizeOf(context).width < Breakpoints.compact)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 8,
|
||||
left: 16,
|
||||
child: const ServerDropdownButton(),
|
||||
)
|
||||
else
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ScrollFadeHeader(scrollProgress: _scrollProgress),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: AppLoadingRing()),
|
||||
error: (error, _) => AppErrorState(
|
||||
message: formatUserError(error, fallback: '首页内容加载失败'),
|
||||
panel: true,
|
||||
onRetry: () => ref.read(libraryOverviewProvider.notifier).refresh(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/mappers/media_image_url.dart';
|
||||
import '../../../shared/widgets/banner_carousel_mixin.dart';
|
||||
import '../../../shared/widgets/banner_carousel_scaffold.dart';
|
||||
|
||||
|
||||
class HomeBannerCarousel extends StatefulWidget {
|
||||
final String baseUrl;
|
||||
final String token;
|
||||
final List<EmbyRawItem> items;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final ValueChanged<EmbyRawItem> onItemTap;
|
||||
final ValueChanged<EmbyRawItem>? onActiveItemChanged;
|
||||
final bool active;
|
||||
|
||||
const HomeBannerCarousel({
|
||||
super.key,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
required this.items,
|
||||
required this.onItemTap,
|
||||
this.imageHeaders,
|
||||
this.onActiveItemChanged,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HomeBannerCarousel> createState() => _HomeBannerCarouselState();
|
||||
}
|
||||
|
||||
class _HomeBannerCarouselState extends State<HomeBannerCarousel>
|
||||
with WidgetsBindingObserver, BannerCarouselMixin<HomeBannerCarousel> {
|
||||
@override
|
||||
int get itemCount => widget.items.length;
|
||||
|
||||
@override
|
||||
bool get isActive => widget.active;
|
||||
|
||||
@override
|
||||
void emitActiveItem() {
|
||||
if (!mounted || !widget.active) return;
|
||||
if (widget.items.isEmpty) return;
|
||||
final safeIndex = currentIndex.clamp(0, widget.items.length - 1);
|
||||
widget.onActiveItemChanged?.call(widget.items[safeIndex]);
|
||||
}
|
||||
|
||||
@override
|
||||
void precacheAdjacentSlides() {
|
||||
if (!mounted || !widget.active || widget.items.length <= 1) return;
|
||||
final next = (currentIndex + 1) % widget.items.length;
|
||||
final nextLogo = _logoUrl(widget.items[next]);
|
||||
final cacheWidth = bannerCacheWidth();
|
||||
|
||||
void precacheUrl(String url, {int? cacheWidth}) {
|
||||
unawaited(
|
||||
precacheImage(
|
||||
ResizeImage.resizeIfNeeded(
|
||||
cacheWidth,
|
||||
null,
|
||||
CachedNetworkImageProvider(url, headers: widget.imageHeaders),
|
||||
),
|
||||
context,
|
||||
onError: (_, _) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
precacheUrl(
|
||||
EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
item: widget.items[next],
|
||||
),
|
||||
cacheWidth: cacheWidth,
|
||||
);
|
||||
if (nextLogo != null) precacheUrl(nextLogo, cacheWidth: 960);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HomeBannerCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.items != widget.items) {
|
||||
resetCarouselItems();
|
||||
}
|
||||
if (oldWidget.active != widget.active) {
|
||||
widget.active ? activateCarousel() : deactivateCarousel();
|
||||
}
|
||||
}
|
||||
|
||||
BannerSlideData _slideDataFor(EmbyRawItem item) {
|
||||
final fallbackItemId = (item.SeriesId?.isNotEmpty ?? false)
|
||||
? item.SeriesId!
|
||||
: item.Id;
|
||||
final genres =
|
||||
(item.extra['Genres'] as List?)?.cast<String>().take(2).toList() ??
|
||||
const <String>[];
|
||||
return BannerSlideData(
|
||||
imageUrl: EmbyImageUrl.detailBanner(
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
item: item,
|
||||
),
|
||||
fallbackImageUrl: EmbyImageUrl.fanart(
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
itemId: fallbackItemId,
|
||||
),
|
||||
imageHeaders: widget.imageHeaders,
|
||||
logoUrl: _logoUrl(item),
|
||||
title: item.Name,
|
||||
overview: item.Overview,
|
||||
rating: item.CommunityRating?.toStringAsFixed(1),
|
||||
primaryMetaLabels: [
|
||||
if (item.ProductionYear != null) item.ProductionYear!.toString(),
|
||||
],
|
||||
secondaryMetaLabels: genres,
|
||||
);
|
||||
}
|
||||
|
||||
String? _logoUrl(EmbyRawItem item) {
|
||||
final logoItemId = (item.SeriesId?.isNotEmpty ?? false)
|
||||
? item.SeriesId!
|
||||
: item.Id;
|
||||
return EmbyImageUrl.logo(
|
||||
baseUrl: widget.baseUrl,
|
||||
token: widget.token,
|
||||
itemId: logoItemId,
|
||||
tag: logoItemId == item.Id ? (item.ImageTags?['Logo']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BannerCarouselScaffold(
|
||||
controller: this,
|
||||
reserveLogoArea: true,
|
||||
slides: [for (final item in widget.items) _slideDataFor(item)],
|
||||
onSlideTap: (index) => widget.onItemTap(widget.items[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class HomeBannerSkeleton extends StatelessWidget {
|
||||
const HomeBannerSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
|
||||
if (compact) {
|
||||
final height = MediaQuery.sizeOf(context).height * 0.55;
|
||||
return SizedBox(height: height);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final height = (constraints.maxWidth * 7 / 16).clamp(260.0, 500.0);
|
||||
return SizedBox(height: height);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/auth.dart';
|
||||
import '../../../providers/home_page_mode_provider.dart';
|
||||
import '../../../providers/server_provider.dart';
|
||||
import '../../../providers/session_provider.dart';
|
||||
import '../../../shared/widgets/auto_dismiss_menu.dart';
|
||||
import '../../../shared/widgets/server_avatar.dart';
|
||||
|
||||
class ServerDropdownButton extends ConsumerWidget {
|
||||
const ServerDropdownButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeSession = ref.watch(activeSessionProvider);
|
||||
final sessionState = ref.watch(sessionProvider);
|
||||
final serverList = ref.watch(serverListProvider);
|
||||
final mode = ref.watch(homePageModeProvider);
|
||||
|
||||
final isRecommend = mode == HomePageMode.recommend;
|
||||
final currentName = isRecommend
|
||||
? '推荐'
|
||||
: (activeSession?.serverName ?? '未连接');
|
||||
|
||||
final pausedIds = serverList.maybeWhen(
|
||||
data: (list) => {
|
||||
for (final s in list)
|
||||
if (s.paused) s.id,
|
||||
},
|
||||
orElse: () => <String>{},
|
||||
);
|
||||
final sessions = sessionState.maybeWhen(
|
||||
data: (data) =>
|
||||
data.sessions.where((s) => !pausedIds.contains(s.serverId)).toList(),
|
||||
orElse: () => <AuthedSession>[],
|
||||
);
|
||||
|
||||
return FPopoverMenu(
|
||||
menuBuilder: autoDismissMenuBuilder,
|
||||
menuAnchor: Alignment.topLeft,
|
||||
childAnchor: Alignment.bottomLeft,
|
||||
menu: [FItemGroup(children: [
|
||||
FItem(
|
||||
prefix: Icon(
|
||||
isRecommend ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
size: 20,
|
||||
color: isRecommend ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.explore_outlined, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('推荐'),
|
||||
],
|
||||
),
|
||||
selected: isRecommend,
|
||||
onPress: () {
|
||||
ref.read(homePageModeProvider.notifier).state =
|
||||
HomePageMode.recommend;
|
||||
},
|
||||
),
|
||||
for (final s in sessions)
|
||||
FItem(
|
||||
prefix: Icon(
|
||||
!isRecommend && s.serverId == activeSession?.serverId
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 20,
|
||||
color: !isRecommend && s.serverId == activeSession?.serverId
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
s.serverName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
selected: !isRecommend && s.serverId == activeSession?.serverId,
|
||||
onPress: () {
|
||||
ref.read(homePageModeProvider.notifier).state =
|
||||
HomePageMode.server;
|
||||
ref.read(sessionProvider.notifier).switchSession(s.serverId);
|
||||
},
|
||||
),
|
||||
])],
|
||||
builder: (context, controller, child) => GestureDetector(
|
||||
onTap: controller.toggle,
|
||||
child: child,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width - 32,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(6, 6, 8, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isRecommend)
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.explore_outlined,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
ServerAvatar(
|
||||
name: activeSession?.serverName ?? '',
|
||||
iconUrl: '',
|
||||
size: 28,
|
||||
isActive: true,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(blurRadius: 8, color: Color(0x80000000)),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../providers/emby_headers_provider.dart';
|
||||
import '../../providers/library_overview_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/shell_backdrop_provider.dart';
|
||||
import '../../shared/constants/layout_constants.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/media_poster_card.dart';
|
||||
|
||||
const _kPageSize = 20;
|
||||
const _kScrollThreshold = 200.0;
|
||||
const _kFields =
|
||||
'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,'
|
||||
'Overview,UserDataLastPlayedDate,OfficialRating,Genres,Status,'
|
||||
'People,Studios,ProviderIds';
|
||||
const _kImageTypes = 'Primary,Backdrop,Thumb,Logo';
|
||||
const _kIncludeTypes = 'Series,Movie,Video,MusicVideo';
|
||||
const _kCollectionIncludeTypes = 'BoxSet,Folder,Series,Movie,Video,MusicVideo';
|
||||
const _kSortBy = 'DateLastContentAdded,DateCreated,SortName';
|
||||
|
||||
class LibraryListPage extends ConsumerStatefulWidget {
|
||||
final String libraryId;
|
||||
|
||||
|
||||
final String? title;
|
||||
|
||||
|
||||
final bool isCollection;
|
||||
|
||||
const LibraryListPage({
|
||||
super.key,
|
||||
required this.libraryId,
|
||||
this.title,
|
||||
this.isCollection = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<LibraryListPage> createState() => _LibraryListPageState();
|
||||
}
|
||||
|
||||
class _LibraryListPageState extends ConsumerState<LibraryListPage> {
|
||||
final List<EmbyRawItem> _items = [];
|
||||
int _startIndex = 0;
|
||||
bool _hasMore = true;
|
||||
bool _isFirstLoad = true;
|
||||
bool _isLoadingMore = false;
|
||||
String? _loadError;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPage();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryListPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.libraryId != widget.libraryId) {
|
||||
_resetAndFetch();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final remaining =
|
||||
_scrollController.position.maxScrollExtent -
|
||||
_scrollController.position.pixels;
|
||||
if (remaining <= _kScrollThreshold && _hasMore && !_isLoadingMore) {
|
||||
_fetchPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _resetAndFetch() {
|
||||
setState(() {
|
||||
_items.clear();
|
||||
_startIndex = 0;
|
||||
_hasMore = true;
|
||||
_isFirstLoad = true;
|
||||
_isLoadingMore = false;
|
||||
_loadError = null;
|
||||
});
|
||||
_fetchPage();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() {
|
||||
_items.clear();
|
||||
_startIndex = 0;
|
||||
_hasMore = true;
|
||||
_isFirstLoad = true;
|
||||
_isLoadingMore = false;
|
||||
_loadError = null;
|
||||
});
|
||||
await _fetchPage();
|
||||
}
|
||||
|
||||
Future<void> _fetchPage() async {
|
||||
if (_isLoadingMore || !_hasMore) return;
|
||||
setState(() {
|
||||
_isLoadingMore = true;
|
||||
_loadError = null;
|
||||
});
|
||||
try {
|
||||
final uc = await ref.read(libraryItemsUseCaseProvider.future);
|
||||
final res = await uc.execute(
|
||||
LibraryItemsReq(
|
||||
parentId: widget.libraryId,
|
||||
includeItemTypes: widget.isCollection
|
||||
? _kCollectionIncludeTypes
|
||||
: _kIncludeTypes,
|
||||
sortBy: _kSortBy,
|
||||
sortOrder: SortOrder.descending,
|
||||
fields: _kFields,
|
||||
enableImageTypes: _kImageTypes,
|
||||
startIndex: _startIndex,
|
||||
limit: _kPageSize,
|
||||
recursive: !widget.isCollection,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
final fetched = res.items;
|
||||
setState(() {
|
||||
_items.addAll(fetched);
|
||||
_startIndex += fetched.length;
|
||||
_hasMore = fetched.length == _kPageSize;
|
||||
_isFirstLoad = false;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadError = e.toString();
|
||||
_isFirstLoad = false;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) return const SizedBox.shrink();
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
final hasBackdrop = ref.watch(
|
||||
shellBackdropProvider.select((state) => state != null),
|
||||
);
|
||||
final immersiveTextColor = hasBackdrop ? Colors.white : null;
|
||||
final libraryName =
|
||||
ref
|
||||
.watch(libraryOverviewProvider)
|
||||
.value
|
||||
?.libraries
|
||||
.where((l) => l.Id == widget.libraryId)
|
||||
.firstOrNull
|
||||
?.Name ??
|
||||
widget.title ??
|
||||
'';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
title: libraryName,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
..._buildContentSlivers(
|
||||
session,
|
||||
imageHeaders,
|
||||
immersiveTextColor: immersiveTextColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildContentSlivers(
|
||||
dynamic session,
|
||||
Map<String, String>? imageHeaders, {
|
||||
required Color? immersiveTextColor,
|
||||
}) {
|
||||
if (_isFirstLoad) {
|
||||
if (_loadError != null) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: AppErrorState(
|
||||
message: formatUserError(_loadError, fallback: '媒体列表加载失败'),
|
||||
foregroundColor: immersiveTextColor,
|
||||
onRetry: _resetAndFetch,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (_items.isEmpty && !_isLoadingMore && _loadError != null) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: AppErrorState(
|
||||
message: formatUserError(_loadError, fallback: '媒体列表加载失败'),
|
||||
foregroundColor: immersiveTextColor,
|
||||
onRetry: _resetAndFetch,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (_items.isEmpty && !_isLoadingMore) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(
|
||||
icon: Icons.inbox_outlined,
|
||||
title: '暂无媒体内容',
|
||||
foregroundColor: immersiveTextColor,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
sliver: SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(_, i) => MediaPosterCard(
|
||||
item: _items[i],
|
||||
width: double.infinity,
|
||||
textColor: immersiveTextColor,
|
||||
imageUrl: EmbyImageUrl.primary(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: _items[i],
|
||||
maxHeight: 360,
|
||||
),
|
||||
preloadImageUrl: EmbyImageUrl.detailBanner(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: _items[i],
|
||||
),
|
||||
imageHeaders: imageHeaders,
|
||||
onTap: () => goMediaDetail(context, _items[i]),
|
||||
),
|
||||
childCount: _items.length,
|
||||
),
|
||||
gridDelegate: mediaGridDelegate,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildFooter(immersiveTextColor: immersiveTextColor),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildFooter({required Color? immersiveTextColor}) {
|
||||
if (_isLoadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
);
|
||||
}
|
||||
if (!_hasMore && _items.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'没有更多了',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: immersiveTextColor?.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/script_widget.dart';
|
||||
import '../../core/domain/usecases/script_widget/script_widget_service.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../shared/constants/layout_constants.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/app_card.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/page_background.dart';
|
||||
import '../../shared/widgets/script_video_card.dart';
|
||||
import '../../shared/widgets/section_header.dart';
|
||||
|
||||
|
||||
const double _kInfiniteScrollThreshold = 200;
|
||||
|
||||
class ModuleAllPage extends ConsumerStatefulWidget {
|
||||
final String widgetId;
|
||||
final String moduleKey;
|
||||
|
||||
|
||||
final Map<String, String> initialParams;
|
||||
|
||||
const ModuleAllPage({
|
||||
super.key,
|
||||
required this.widgetId,
|
||||
required this.moduleKey,
|
||||
this.initialParams = const <String, String>{},
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ModuleAllPage> createState() => _ModuleAllPageState();
|
||||
}
|
||||
|
||||
class _ModuleAllPageState extends ConsumerState<ModuleAllPage> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
ScriptWidgetModule? _module;
|
||||
Map<String, Object?> _parameters = <String, Object?>{};
|
||||
|
||||
|
||||
final List<ScriptWidgetContentSection> _sections =
|
||||
<ScriptWidgetContentSection>[];
|
||||
|
||||
String? _initializedWidgetId;
|
||||
String? _initializationError;
|
||||
|
||||
|
||||
bool _isFirstLoad = true;
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMore = true;
|
||||
String? _loadError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final remaining =
|
||||
_scrollController.position.maxScrollExtent -
|
||||
_scrollController.position.pixels;
|
||||
if (remaining <= _kInfiniteScrollThreshold) {
|
||||
unawaited(_fetchNextPage());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initialize(InstalledScriptWidget installedWidget) async {
|
||||
if (_initializedWidgetId == installedWidget.manifest.id) return;
|
||||
_initializedWidgetId = installedWidget.manifest.id;
|
||||
|
||||
final module = installedWidget.manifest.modules
|
||||
.where((candidate) => _moduleKey(candidate) == widget.moduleKey)
|
||||
.firstOrNull;
|
||||
if (module == null) {
|
||||
setState(() {
|
||||
_initializationError = '未找到模块功能:${widget.moduleKey}';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedParameters = buildScriptWidgetParameterDefaults(module.params);
|
||||
for (final entry in widget.initialParams.entries) {
|
||||
if (resolvedParameters.containsKey(entry.key)) {
|
||||
resolvedParameters[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_module = module;
|
||||
_parameters = resolvedParameters;
|
||||
_initializationError = null;
|
||||
});
|
||||
await _fetchNextPage();
|
||||
}
|
||||
|
||||
String _moduleKey(ScriptWidgetModule module) {
|
||||
return module.id.isEmpty ? module.functionName : module.id;
|
||||
}
|
||||
|
||||
ScriptWidgetParam? get _pagingParameter {
|
||||
return _module?.params
|
||||
.where(
|
||||
(parameter) => parameter.type == 'page' || parameter.type == 'offset',
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
|
||||
bool get _supportsPagination => _pagingParameter != null;
|
||||
|
||||
Future<void> _fetchNextPage() async {
|
||||
if (_isLoadingMore || !_hasMore) return;
|
||||
final module = _module;
|
||||
if (module == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingMore = true;
|
||||
_loadError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
final freshSections = await service.loadModuleSections(
|
||||
widget.widgetId,
|
||||
module,
|
||||
_parameters,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final contentItemCount = freshSections.fold<int>(
|
||||
0,
|
||||
(count, section) =>
|
||||
count +
|
||||
section.items
|
||||
.where((item) => item.type.toLowerCase() != 'text')
|
||||
.length,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_mergeSections(freshSections);
|
||||
if (!_supportsPagination || contentItemCount == 0) {
|
||||
_hasMore = false;
|
||||
} else {
|
||||
_advancePagingCursor();
|
||||
}
|
||||
_isFirstLoad = false;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadError = formatUserError(error);
|
||||
_isFirstLoad = false;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _mergeSections(List<ScriptWidgetContentSection> freshSections) {
|
||||
for (final freshSection in freshSections) {
|
||||
final existingIndex = _sections.indexWhere(
|
||||
(existing) => existing.title == freshSection.title,
|
||||
);
|
||||
if (existingIndex == -1) {
|
||||
_sections.add(freshSection);
|
||||
continue;
|
||||
}
|
||||
final merged = <ScriptVideoItem>[
|
||||
..._sections[existingIndex].items,
|
||||
...freshSection.items,
|
||||
];
|
||||
_sections[existingIndex] = ScriptWidgetContentSection(
|
||||
title: freshSection.title,
|
||||
items: merged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _advancePagingCursor() {
|
||||
final module = _module;
|
||||
final pagingParameter = _pagingParameter;
|
||||
if (module == null || pagingParameter == null) return;
|
||||
|
||||
final minimumValue = pagingParameter.type == 'page' ? 1 : 0;
|
||||
final currentValue =
|
||||
int.tryParse(_parameters[pagingParameter.name]?.toString() ?? '') ??
|
||||
minimumValue;
|
||||
final countParameter = module.params
|
||||
.where((parameter) => parameter.type == 'count')
|
||||
.firstOrNull;
|
||||
final pageStep = pagingParameter.type == 'offset'
|
||||
? int.tryParse(_parameters[countParameter?.name]?.toString() ?? '') ??
|
||||
20
|
||||
: 1;
|
||||
_parameters[pagingParameter.name] = currentValue + pageStep;
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() {
|
||||
_sections.clear();
|
||||
_isFirstLoad = true;
|
||||
_isLoadingMore = false;
|
||||
_hasMore = true;
|
||||
_loadError = null;
|
||||
final module = _module;
|
||||
if (module != null) {
|
||||
_parameters = buildScriptWidgetParameterDefaults(module.params);
|
||||
for (final entry in widget.initialParams.entries) {
|
||||
if (_parameters.containsKey(entry.key)) {
|
||||
_parameters[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await _fetchNextPage();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final installedWidget = ref.watch(
|
||||
installedScriptWidgetProvider(widget.widgetId),
|
||||
);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: PageBackground()),
|
||||
installedWidget.when(
|
||||
loading: () => const Center(child: AppLoadingRing()),
|
||||
error: (error, _) => Center(
|
||||
child: AppErrorState(
|
||||
message: formatUserError(error),
|
||||
onRetry: () => ref.invalidate(
|
||||
installedScriptWidgetProvider(widget.widgetId),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (widgetData) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_initialize(widgetData));
|
||||
});
|
||||
return _buildContent(widgetData);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(InstalledScriptWidget installedWidget) {
|
||||
final moduleTitle = _module?.title.trim();
|
||||
final title = moduleTitle == null || moduleTitle.isEmpty
|
||||
? installedWidget.manifest.title
|
||||
: moduleTitle;
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
AppSliverHeader(title: title),
|
||||
..._buildBodySlivers(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildBodySlivers() {
|
||||
if (_initializationError case final initializationError?) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: AppErrorState(message: initializationError),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_module == null || (_isFirstLoad && _isLoadingMore)) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
];
|
||||
}
|
||||
final firstLoadError = _isFirstLoad ? _loadError : null;
|
||||
if (firstLoadError != null) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: AppErrorState(
|
||||
message: firstLoadError,
|
||||
onRetry: _fetchNextPage,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_sections.every((section) => section.items.isEmpty)) {
|
||||
return const [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(
|
||||
compact: true,
|
||||
icon: Icons.video_library_outlined,
|
||||
title: '没有可显示的内容',
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate.fixed([
|
||||
for (final section in _sections) ...[
|
||||
_ModuleGridSection(
|
||||
section: section,
|
||||
widgetId: widget.widgetId,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildFooter()),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
if (_isLoadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
);
|
||||
}
|
||||
if (_loadError case final loadError?) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
|
||||
child: AppErrorState(
|
||||
compact: true,
|
||||
message: loadError,
|
||||
onRetry: _fetchNextPage,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!_hasMore) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'没有更多了',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox(height: 40);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModuleGridSection extends StatelessWidget {
|
||||
final ScriptWidgetContentSection section;
|
||||
final String widgetId;
|
||||
|
||||
const _ModuleGridSection({required this.section, required this.widgetId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusItems = section.items
|
||||
.where((item) => item.type.toLowerCase() == 'text')
|
||||
.toList(growable: false);
|
||||
final contentItems = section.items
|
||||
.where((item) => item.type.toLowerCase() != 'text')
|
||||
.toList(growable: false);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(label: section.title, count: contentItems.length),
|
||||
for (final statusItem in statusItems) ...[
|
||||
_ModuleStatusCard(item: statusItem),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (contentItems.isNotEmpty)
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: contentItems.length,
|
||||
gridDelegate: mediaGridDelegate,
|
||||
itemBuilder: (context, index) {
|
||||
final item = contentItems[index];
|
||||
return ScriptVideoCard(
|
||||
item: item,
|
||||
width: double.infinity,
|
||||
onTap: () => context.push(
|
||||
'/modules/${Uri.encodeComponent(widgetId)}/detail',
|
||||
extra: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModuleStatusCard extends StatelessWidget {
|
||||
final ScriptVideoItem item;
|
||||
|
||||
const _ModuleStatusCard({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalizedId = item.id.toLowerCase();
|
||||
final indicatesError =
|
||||
normalizedId.startsWith('err') ||
|
||||
item.title.contains('失败') ||
|
||||
item.title.contains('错误') ||
|
||||
item.title.contains('拦截');
|
||||
final foregroundColor = indicatesError
|
||||
? Theme.of(context).colorScheme.error
|
||||
: context.appTokens.mutedForeground;
|
||||
|
||||
return AppCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
indicatesError
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
color: foregroundColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title.isEmpty ? '模块提示' : item.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (item.description.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/script_widget.dart';
|
||||
import '../../core/domain/usecases/script_widget/script_widget_service.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../shared/constants/layout_constants.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/app_card.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/error_media_row.dart';
|
||||
import '../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../shared/widgets/media_poster_card.dart';
|
||||
import '../../shared/widgets/media_row_metrics.dart';
|
||||
import '../../shared/widgets/page_background.dart';
|
||||
import '../../shared/widgets/script_video_card.dart';
|
||||
import '../../shared/widgets/section_header.dart';
|
||||
import '../../shared/widgets/skeleton_media_row.dart';
|
||||
import 'script_widget_parameter_form.dart';
|
||||
|
||||
class ModuleBrowsePage extends ConsumerStatefulWidget {
|
||||
final String widgetId;
|
||||
|
||||
const ModuleBrowsePage({super.key, required this.widgetId});
|
||||
|
||||
@override
|
||||
ConsumerState<ModuleBrowsePage> createState() => _ModuleBrowsePageState();
|
||||
}
|
||||
|
||||
class _ModuleBrowsePageState extends ConsumerState<ModuleBrowsePage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final Map<String, Map<String, Object?>> _moduleParameters =
|
||||
<String, Map<String, Object?>>{};
|
||||
|
||||
InstalledScriptWidget? _installedWidget;
|
||||
List<ScriptWidgetContentSection> _searchSections =
|
||||
const <ScriptWidgetContentSection>[];
|
||||
bool _searchMode = false;
|
||||
bool _filtersExpanded = false;
|
||||
bool _loadingSearch = false;
|
||||
String? _searchError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<ScriptWidgetModule> _contentModules(ScriptWidgetManifest manifest) {
|
||||
return manifest.modules
|
||||
.where(
|
||||
(module) =>
|
||||
module.type != 'danmu' &&
|
||||
module.type != 'stream' &&
|
||||
module.id != 'loadResource',
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Map<String, Object?> _parametersFor(ScriptWidgetModule module) {
|
||||
return _moduleParameters.putIfAbsent(
|
||||
_moduleKey(module),
|
||||
() => buildScriptWidgetParameterDefaults(module.params),
|
||||
);
|
||||
}
|
||||
|
||||
List<ScriptWidgetParam> _searchParameters(ScriptWidgetModule? searchModule) {
|
||||
if (searchModule == null) return const <ScriptWidgetParam>[];
|
||||
return searchModule.params
|
||||
.where((parameter) {
|
||||
final normalizedName = parameter.name.toLowerCase();
|
||||
final isKeywordParameter = const <String>{
|
||||
'keyword',
|
||||
'query',
|
||||
'search',
|
||||
'title',
|
||||
}.contains(normalizedName);
|
||||
final isInternalParameter = const <String>{
|
||||
'constant',
|
||||
'page',
|
||||
'offset',
|
||||
}.contains(parameter.type);
|
||||
return !isKeywordParameter && !isInternalParameter;
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final searchModule = _installedWidget?.manifest.search;
|
||||
if (searchModule == null) return;
|
||||
final keyword = _searchController.text.trim();
|
||||
if (keyword.isEmpty) return;
|
||||
|
||||
final searchParameters = <String, Object?>{
|
||||
...buildScriptWidgetParameterDefaults(searchModule.params),
|
||||
..._parametersFor(searchModule),
|
||||
};
|
||||
final keywordParameter = searchModule.params
|
||||
.where(
|
||||
(parameter) => const <String>{
|
||||
'keyword',
|
||||
'query',
|
||||
'search',
|
||||
'title',
|
||||
}.contains(parameter.name.toLowerCase()),
|
||||
)
|
||||
.firstOrNull;
|
||||
searchParameters[keywordParameter?.name ?? 'keyword'] = keyword;
|
||||
|
||||
setState(() {
|
||||
_loadingSearch = true;
|
||||
_searchError = null;
|
||||
});
|
||||
try {
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
final items = await service.search(widget.widgetId, searchParameters);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchSections = <ScriptWidgetContentSection>[
|
||||
ScriptWidgetContentSection(title: '搜索结果', items: items),
|
||||
];
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchError = formatUserError(error);
|
||||
_searchSections = const <ScriptWidgetContentSection>[];
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loadingSearch = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScriptWidgetParam? get _searchPagingParameter {
|
||||
final searchModule = _installedWidget?.manifest.search;
|
||||
return searchModule?.params
|
||||
.where(
|
||||
(parameter) => parameter.type == 'page' || parameter.type == 'offset',
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
Future<void> _changeSearchPage(int direction) async {
|
||||
final searchModule = _installedWidget?.manifest.search;
|
||||
final pagingParameter = _searchPagingParameter;
|
||||
if (searchModule == null || pagingParameter == null) return;
|
||||
|
||||
final parameters = _parametersFor(searchModule);
|
||||
final minimumValue = pagingParameter.type == 'page' ? 1 : 0;
|
||||
final currentValue =
|
||||
int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ??
|
||||
minimumValue;
|
||||
final countParameter = searchModule.params
|
||||
.where((parameter) => parameter.type == 'count')
|
||||
.firstOrNull;
|
||||
final pageStep = pagingParameter.type == 'offset'
|
||||
? int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20
|
||||
: 1;
|
||||
parameters[pagingParameter.name] = (currentValue + direction * pageStep)
|
||||
.clamp(minimumValue, 1 << 31);
|
||||
await _search();
|
||||
}
|
||||
|
||||
bool get _canGoBackSearchPage {
|
||||
final searchModule = _installedWidget?.manifest.search;
|
||||
final pagingParameter = _searchPagingParameter;
|
||||
if (searchModule == null || pagingParameter == null) return false;
|
||||
final minimumValue = pagingParameter.type == 'page' ? 1 : 0;
|
||||
final currentValue =
|
||||
int.tryParse(
|
||||
_parametersFor(searchModule)[pagingParameter.name]?.toString() ?? '',
|
||||
) ??
|
||||
minimumValue;
|
||||
return currentValue > minimumValue;
|
||||
}
|
||||
|
||||
String get _currentSearchPageLabel {
|
||||
final searchModule = _installedWidget?.manifest.search;
|
||||
final pagingParameter = _searchPagingParameter;
|
||||
if (searchModule == null || pagingParameter == null) return '第 1 页';
|
||||
final parameters = _parametersFor(searchModule);
|
||||
final minimumValue = pagingParameter.type == 'page' ? 1 : 0;
|
||||
final currentValue =
|
||||
int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ??
|
||||
minimumValue;
|
||||
if (pagingParameter.type == 'page') return '第 $currentValue 页';
|
||||
|
||||
final countParameter = searchModule.params
|
||||
.where((parameter) => parameter.type == 'count')
|
||||
.firstOrNull;
|
||||
final pageSize =
|
||||
int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20;
|
||||
return '第 ${(currentValue ~/ pageSize) + 1} 页';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final installedWidget = ref.watch(
|
||||
installedScriptWidgetProvider(widget.widgetId),
|
||||
);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: PageBackground()),
|
||||
installedWidget.when(
|
||||
loading: () => const Center(child: AppLoadingRing()),
|
||||
error: (error, _) => Center(
|
||||
child: AppErrorState(
|
||||
message: formatUserError(error),
|
||||
onRetry: () => ref.invalidate(
|
||||
installedScriptWidgetProvider(widget.widgetId),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (widgetData) {
|
||||
_installedWidget = widgetData;
|
||||
return _buildContent(widgetData);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(InstalledScriptWidget installedWidget) {
|
||||
final manifest = installedWidget.manifest;
|
||||
final contentModules = _contentModules(manifest);
|
||||
final searchParameters = _searchParameters(manifest.search);
|
||||
return CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
title: manifest.title,
|
||||
bottom: _searchMode
|
||||
? PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: _SearchControlBar(
|
||||
searchController: _searchController,
|
||||
searchHint: manifest.search?.description,
|
||||
filtersExpanded: _filtersExpanded,
|
||||
hasFilters: searchParameters.isNotEmpty,
|
||||
onSearch: _search,
|
||||
onToggleFilters: () {
|
||||
setState(() => _filtersExpanded = !_filtersExpanded);
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
if (manifest.search != null)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchMode = !_searchMode;
|
||||
_filtersExpanded = false;
|
||||
});
|
||||
},
|
||||
tooltip: _searchMode ? '关闭搜索' : '搜索',
|
||||
icon: Icon(
|
||||
_searchMode ? Icons.close_rounded : Icons.search_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (manifest.description.trim().isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _ModuleIdentityCard(installedWidget: installedWidget),
|
||||
),
|
||||
),
|
||||
if (_searchMode)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 56),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate.fixed([
|
||||
_buildSearchFilterPanel(manifest),
|
||||
if (_filtersExpanded && searchParameters.isNotEmpty)
|
||||
const SizedBox(height: 18),
|
||||
_buildSearchResults(),
|
||||
]),
|
||||
),
|
||||
)
|
||||
else if (contentModules.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(
|
||||
icon: Icons.video_library_outlined,
|
||||
title: '没有内容功能',
|
||||
message: '该模块没有可浏览的内容功能。',
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate.fixed([
|
||||
for (final module in contentModules)
|
||||
_ModulePreviewSection(
|
||||
widgetId: widget.widgetId,
|
||||
module: module,
|
||||
),
|
||||
const SizedBox(height: 56),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchFilterPanel(ScriptWidgetManifest manifest) {
|
||||
final searchModule = manifest.search;
|
||||
final parameters = _searchParameters(searchModule);
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: !_filtersExpanded || searchModule == null || parameters.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: AppCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (searchModule.description.trim().isNotEmpty) ...[
|
||||
Text(
|
||||
searchModule.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
ScriptWidgetParameterForm(
|
||||
parameters: parameters,
|
||||
initialValues: _parametersFor(searchModule),
|
||||
onChanged: (values) {
|
||||
_parametersFor(searchModule).addAll(values);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FButton(
|
||||
onPress: _search,
|
||||
child: const Text('应用并搜索'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResults() {
|
||||
if (_loadingSearch) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 64),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
);
|
||||
}
|
||||
if (_searchError case final searchError?) {
|
||||
return AppErrorState(
|
||||
compact: true,
|
||||
message: searchError,
|
||||
onRetry: _search,
|
||||
);
|
||||
}
|
||||
if (_searchSections.every((section) => section.items.isEmpty)) {
|
||||
return const EmptyState(
|
||||
compact: true,
|
||||
icon: Icons.search_rounded,
|
||||
title: '输入关键词开始搜索',
|
||||
message: '搜索结果会显示在这里。',
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final section in _searchSections) ...[
|
||||
_ModuleSearchSection(section: section, widgetId: widget.widgetId),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (_searchPagingParameter != null)
|
||||
_PaginationBar(
|
||||
canGoBack: _canGoBackSearchPage,
|
||||
pageLabel: _currentSearchPageLabel,
|
||||
onPrevious: () => unawaited(_changeSearchPage(-1)),
|
||||
onNext: () => unawaited(_changeSearchPage(1)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _moduleKey(ScriptWidgetModule module) {
|
||||
return module.id.isEmpty ? module.functionName : module.id;
|
||||
}
|
||||
|
||||
class _ModulePreviewSection extends ConsumerWidget {
|
||||
final String widgetId;
|
||||
final ScriptWidgetModule module;
|
||||
|
||||
const _ModulePreviewSection({required this.widgetId, required this.module});
|
||||
|
||||
|
||||
ScriptWidgetParam? get _primaryEnumerationParam {
|
||||
return module.params
|
||||
.where(
|
||||
(parameter) =>
|
||||
parameter.type == 'enumeration' &&
|
||||
parameter.enumOptions.length >= 2,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final previewKey = (widgetId: widgetId, moduleKey: _moduleKey(module));
|
||||
final sections = ref.watch(scriptWidgetModulePreviewProvider(previewKey));
|
||||
final enumerationParam = _primaryEnumerationParam;
|
||||
final categoryRow = enumerationParam == null
|
||||
? const SizedBox.shrink()
|
||||
: _ModuleCategoryRow(
|
||||
widgetId: widgetId,
|
||||
moduleKey: _moduleKey(module),
|
||||
moduleTitle: module.title,
|
||||
parameter: enumerationParam,
|
||||
);
|
||||
return sections.when(
|
||||
loading: () => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [categoryRow, SkeletonMediaRow(title: module.title)],
|
||||
),
|
||||
error: (error, _) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
categoryRow,
|
||||
ErrorMediaRow(
|
||||
title: module.title,
|
||||
message: formatUserError(error),
|
||||
onRetry: () =>
|
||||
ref.invalidate(scriptWidgetModulePreviewProvider(previewKey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
data: (loadedSections) {
|
||||
final visibleSections = loadedSections
|
||||
.where((section) => section.items.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (visibleSections.isEmpty && enumerationParam == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
categoryRow,
|
||||
for (final section in visibleSections)
|
||||
_ModulePreviewRow(
|
||||
widgetId: widgetId,
|
||||
moduleKey: _moduleKey(module),
|
||||
section: section,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _ModuleCategoryRow extends StatelessWidget {
|
||||
final String widgetId;
|
||||
final String moduleKey;
|
||||
final String moduleTitle;
|
||||
final ScriptWidgetParam parameter;
|
||||
|
||||
const _ModuleCategoryRow({
|
||||
required this.widgetId,
|
||||
required this.moduleKey,
|
||||
required this.moduleTitle,
|
||||
required this.parameter,
|
||||
});
|
||||
|
||||
static const double _cardHeight = 74;
|
||||
static const double _cardWidth = 168;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final options = parameter.enumOptions;
|
||||
final headerLabel = parameter.title.trim().isEmpty
|
||||
? '$moduleTitle · 类型'
|
||||
: '$moduleTitle · ${parameter.title.trim()}';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
|
||||
child: SectionHeader(label: headerLabel, count: options.length),
|
||||
),
|
||||
SizedBox(
|
||||
height: _cardHeight,
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, scrollController) => ListView.separated(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
itemCount: options.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final option = options[index];
|
||||
return _CategoryCard(
|
||||
label: option.title.isEmpty ? option.value : option.title,
|
||||
width: _cardWidth,
|
||||
onTap: () => _openCategory(context, option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openCategory(BuildContext context, ScriptWidgetOption option) {
|
||||
final targetUri = Uri(
|
||||
path:
|
||||
'/modules/${Uri.encodeComponent(widgetId)}/module/'
|
||||
'${Uri.encodeComponent(moduleKey)}',
|
||||
queryParameters: <String, String>{parameter.name: option.value},
|
||||
);
|
||||
context.push(targetUri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryCard extends StatefulWidget {
|
||||
final String label;
|
||||
final double width;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CategoryCard({
|
||||
required this.label,
|
||||
required this.width,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CategoryCard> createState() => _CategoryCardState();
|
||||
}
|
||||
|
||||
class _CategoryCardState extends State<_CategoryCard> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
return SizedBox(
|
||||
width: widget.width,
|
||||
child: AppCard(
|
||||
filled: true,
|
||||
enableHover: false,
|
||||
radius: tokens.radiusCard,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
onTap: widget.onTap,
|
||||
onHoverChanged: (isHovered) {
|
||||
if (_isHovered == isHovered) return;
|
||||
setState(() => _isHovered = isHovered);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.style_outlined,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary.withValues(
|
||||
alpha: _isHovered ? 0.9 : 0.65,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModulePreviewRow extends StatelessWidget {
|
||||
final String widgetId;
|
||||
final String moduleKey;
|
||||
final ScriptWidgetContentSection section;
|
||||
|
||||
const _ModulePreviewRow({
|
||||
required this.widgetId,
|
||||
required this.moduleKey,
|
||||
required this.section,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusItems = section.items
|
||||
.where((item) => item.type.toLowerCase() == 'text')
|
||||
.toList(growable: false);
|
||||
final contentItems = section.items
|
||||
.where((item) => item.type.toLowerCase() != 'text')
|
||||
.take(20)
|
||||
.toList(growable: false);
|
||||
final compact = MediaRowMetrics.isCompact(context);
|
||||
final cardWidth = MediaRowMetrics.cardWidth(
|
||||
MediaCardVariant.poster,
|
||||
compact: compact,
|
||||
);
|
||||
final rowHeight = MediaRowMetrics.rowHeight(
|
||||
MediaCardVariant.poster,
|
||||
compact: compact,
|
||||
);
|
||||
final allRoute =
|
||||
'/modules/${Uri.encodeComponent(widgetId)}/module/'
|
||||
'${Uri.encodeComponent(moduleKey)}';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (contentItems.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
|
||||
child: SectionHeader(
|
||||
label: section.title,
|
||||
count: contentItems.length,
|
||||
trailing: TextButton(
|
||||
onPressed: () => context.push(allRoute),
|
||||
child: const Text('查看全部'),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final statusItem in statusItems)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 12),
|
||||
child: _ModuleStatusCard(item: statusItem),
|
||||
),
|
||||
if (contentItems.isNotEmpty)
|
||||
SizedBox(
|
||||
height: rowHeight,
|
||||
child: HoverScrollableRow(
|
||||
builder: (_, scrollController) => ListView.separated(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
itemCount: contentItems.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 14),
|
||||
itemBuilder: (context, index) {
|
||||
final item = contentItems[index];
|
||||
return ScriptVideoCard(
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
onTap: () => context.push(
|
||||
'/modules/${Uri.encodeComponent(widgetId)}/detail',
|
||||
extra: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModuleSearchSection extends StatelessWidget {
|
||||
final ScriptWidgetContentSection section;
|
||||
final String widgetId;
|
||||
|
||||
const _ModuleSearchSection({required this.section, required this.widgetId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusItems = section.items
|
||||
.where((item) => item.type.toLowerCase() == 'text')
|
||||
.toList(growable: false);
|
||||
final contentItems = section.items
|
||||
.where((item) => item.type.toLowerCase() != 'text')
|
||||
.toList(growable: false);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(label: section.title, count: contentItems.length),
|
||||
for (final statusItem in statusItems) ...[
|
||||
_ModuleStatusCard(item: statusItem),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (contentItems.isNotEmpty)
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: contentItems.length,
|
||||
gridDelegate: mediaGridDelegate,
|
||||
itemBuilder: (context, index) {
|
||||
final item = contentItems[index];
|
||||
return ScriptVideoCard(
|
||||
item: item,
|
||||
width: double.infinity,
|
||||
onTap: () => context.push(
|
||||
'/modules/${Uri.encodeComponent(widgetId)}/detail',
|
||||
extra: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModuleIdentityCard extends StatelessWidget {
|
||||
final InstalledScriptWidget installedWidget;
|
||||
|
||||
const _ModuleIdentityCard({required this.installedWidget});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final manifest = installedWidget.manifest;
|
||||
return AppCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.extension_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
manifest.description,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
[
|
||||
if (manifest.author.isNotEmpty) manifest.author,
|
||||
'v${manifest.version}',
|
||||
].join(' · '),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModuleStatusCard extends StatelessWidget {
|
||||
final ScriptVideoItem item;
|
||||
|
||||
const _ModuleStatusCard({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalizedId = item.id.toLowerCase();
|
||||
final indicatesError =
|
||||
normalizedId.startsWith('err') ||
|
||||
item.title.contains('失败') ||
|
||||
item.title.contains('错误') ||
|
||||
item.title.contains('拦截');
|
||||
final foregroundColor = indicatesError
|
||||
? Theme.of(context).colorScheme.error
|
||||
: context.appTokens.mutedForeground;
|
||||
return AppCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
indicatesError
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
color: foregroundColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title.isEmpty ? '模块提示' : item.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (item.description.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchControlBar extends StatelessWidget {
|
||||
final TextEditingController searchController;
|
||||
final String? searchHint;
|
||||
final bool filtersExpanded;
|
||||
final bool hasFilters;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onToggleFilters;
|
||||
|
||||
const _SearchControlBar({
|
||||
required this.searchController,
|
||||
required this.searchHint,
|
||||
required this.filtersExpanded,
|
||||
required this.hasFilters,
|
||||
required this.onSearch,
|
||||
required this.onToggleFilters,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: searchHint?.trim().isNotEmpty == true
|
||||
? searchHint
|
||||
: '输入关键词',
|
||||
prefixIcon: const Icon(Icons.search_rounded, size: 19),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.05,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (_) => onSearch(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: onSearch,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
tooltip: '搜索',
|
||||
),
|
||||
if (hasFilters)
|
||||
IconButton(
|
||||
onPressed: onToggleFilters,
|
||||
icon: Icon(
|
||||
Icons.tune_rounded,
|
||||
color: filtersExpanded
|
||||
? theme.colorScheme.primary
|
||||
: tokens.mutedForeground,
|
||||
),
|
||||
tooltip: filtersExpanded ? '收起筛选' : '展开筛选',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaginationBar extends StatelessWidget {
|
||||
final bool canGoBack;
|
||||
final String pageLabel;
|
||||
final VoidCallback onPrevious;
|
||||
final VoidCallback onNext;
|
||||
|
||||
const _PaginationBar({
|
||||
required this.canGoBack,
|
||||
required this.pageLabel,
|
||||
required this.onPrevious,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: canGoBack ? onPrevious : null,
|
||||
child: const Text('上一页'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Text(
|
||||
pageLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
FButton(onPress: onNext, child: const Text('下一页')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/contracts/script_widget.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/app_dialog.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_inline_alert.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_snack_bar.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/hover_scrollable_row.dart';
|
||||
import '../../shared/widgets/script_video_card.dart';
|
||||
import '../detail/widgets/detail_hero_panel.dart';
|
||||
import '../detail/widgets/detail_immersive_scaffold.dart';
|
||||
import '../detail/widgets/detail_nav_bar.dart';
|
||||
import '../player/player_launcher.dart';
|
||||
|
||||
const _moduleDetailChromeColor = Color(0xFF08090C);
|
||||
|
||||
class ModuleDetailPage extends ConsumerStatefulWidget {
|
||||
final String widgetId;
|
||||
final ScriptVideoItem seedItem;
|
||||
|
||||
const ModuleDetailPage({
|
||||
super.key,
|
||||
required this.widgetId,
|
||||
required this.seedItem,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ModuleDetailPage> createState() => _ModuleDetailPageState();
|
||||
}
|
||||
|
||||
class _ModuleDetailPageState extends ConsumerState<ModuleDetailPage> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ValueNotifier<double> _scrollProgress = ValueNotifier<double>(0);
|
||||
|
||||
late ScriptVideoItem _item;
|
||||
bool _loadingDetail = false;
|
||||
bool _loadingResource = false;
|
||||
String? _detailError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_item = widget.seedItem;
|
||||
_scrollController.addListener(_updateScrollProgress);
|
||||
unawaited(_loadDetail());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_updateScrollProgress);
|
||||
_scrollController.dispose();
|
||||
_scrollProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateScrollProgress() {
|
||||
_scrollProgress.value = (_scrollController.offset / 180).clamp(0, 1);
|
||||
}
|
||||
|
||||
Future<void> _loadDetail() async {
|
||||
final link = widget.seedItem.link.trim();
|
||||
if (link.isEmpty) return;
|
||||
setState(() {
|
||||
_loadingDetail = true;
|
||||
_detailError = null;
|
||||
});
|
||||
try {
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
final loadedItem = await service.loadDetail(widget.widgetId, link);
|
||||
if (!mounted || loadedItem == null) return;
|
||||
setState(() {
|
||||
_item = _mergeSeedWithDetail(widget.seedItem, loadedItem);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_detailError = formatUserError(error);
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadingDetail = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playItem(ScriptVideoItem playableItem) async {
|
||||
if (_loadingResource) return;
|
||||
setState(() {
|
||||
_loadingResource = true;
|
||||
});
|
||||
try {
|
||||
final resources = await _resolveResources(playableItem);
|
||||
if (!mounted) return;
|
||||
if (resources.isEmpty) {
|
||||
showAppToast(context, '模块没有返回可播放地址', tone: AppAlertTone.error);
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedResource = resources.length == 1
|
||||
? resources.first
|
||||
: await showAppRawDialog<ScriptVideoResource>(
|
||||
context,
|
||||
builder: (_) => _ResourcePickerDialog(resources: resources),
|
||||
);
|
||||
if (selectedResource == null || !mounted) return;
|
||||
await _launchResource(playableItem, selectedResource);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
showAppToast(context, formatUserError(error), tone: AppAlertTone.error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadingResource = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ScriptVideoResource>> _resolveResources(
|
||||
ScriptVideoItem playableItem,
|
||||
) async {
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
final itemJson = playableItem.toJson();
|
||||
final resources = await service
|
||||
.loadResources(widget.widgetId, <String, Object?>{
|
||||
...itemJson,
|
||||
'item': itemJson,
|
||||
'link': playableItem.link,
|
||||
'url': playableItem.videoUrl,
|
||||
'videoUrl': playableItem.videoUrl,
|
||||
});
|
||||
if (resources.isNotEmpty) return resources;
|
||||
final videoUrl = playableItem.videoUrl.trim();
|
||||
if (videoUrl.isEmpty) return const <ScriptVideoResource>[];
|
||||
return <ScriptVideoResource>[
|
||||
ScriptVideoResource(
|
||||
name: playableItem.title,
|
||||
url: videoUrl,
|
||||
playerType: playableItem.playerType,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _launchResource(
|
||||
ScriptVideoItem playableItem,
|
||||
ScriptVideoResource resource,
|
||||
) async {
|
||||
final playerType = resource.playerType.trim().toLowerCase();
|
||||
final shouldUseInternalPlayer =
|
||||
playerType.isEmpty || playerType == 'app' || playerType == 'internal';
|
||||
if (!shouldUseInternalPlayer) {
|
||||
final launched = await launchUrl(
|
||||
Uri.parse(resource.url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && mounted) {
|
||||
showAppToast(context, '无法打开外部播放器', tone: AppAlertTone.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final itemIdentity = base64Url
|
||||
.encode(
|
||||
utf8.encode(
|
||||
'${widget.widgetId}:${playableItem.id}:${playableItem.link}',
|
||||
),
|
||||
)
|
||||
.replaceAll('=', '');
|
||||
await PlayerLauncher.launch(
|
||||
context: context,
|
||||
itemId: 'script-$itemIdentity',
|
||||
directStream: DirectStreamLaunchData(
|
||||
url: resource.url,
|
||||
headers: resource.customHeaders,
|
||||
title: playableItem.title.isEmpty ? _item.title : playableItem.title,
|
||||
durationSeconds: playableItem.duration,
|
||||
seriesName: playableItem == _item ? null : _item.title,
|
||||
episode: playableItem.episode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backdropUrl = _firstNonEmpty(<String>[
|
||||
_item.backdropPath,
|
||||
..._item.backdropPaths,
|
||||
_item.detailPoster,
|
||||
_item.posterPath,
|
||||
_item.coverUrl,
|
||||
]);
|
||||
final posterUrl = _firstNonEmpty(<String>[
|
||||
_item.detailPoster,
|
||||
_item.posterPath,
|
||||
_item.coverUrl,
|
||||
]);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _moduleDetailChromeColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
DetailImmersiveScaffold(
|
||||
scrollController: _scrollController,
|
||||
scrollProgress: _scrollProgress,
|
||||
backdropUrl: backdropUrl,
|
||||
fallbackUrls: _item.backdropPaths,
|
||||
compact: MediaQuery.sizeOf(context).width < 700,
|
||||
horizontalPadding: MediaQuery.sizeOf(context).width < 700 ? 18 : 32,
|
||||
child: _buildDetailContent(posterUrl),
|
||||
),
|
||||
DetailNavBar(
|
||||
scrollProgress: _scrollProgress,
|
||||
title: _item.title,
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailContent(String? posterUrl) {
|
||||
final metaParts = <String>[
|
||||
if (_item.releaseDate.isNotEmpty) _item.releaseDate,
|
||||
if (_item.mediaType.isNotEmpty) _item.mediaType,
|
||||
if (_item.durationText.isNotEmpty) _item.durationText,
|
||||
];
|
||||
final genres = _item.genreItems
|
||||
.map((genre) => genre.title)
|
||||
.where((title) => title.trim().isNotEmpty)
|
||||
.toList();
|
||||
if (_item.genreTitle.trim().isNotEmpty) genres.add(_item.genreTitle);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DetailHeroPanel(
|
||||
title: _item.title.isEmpty ? _item.id : _item.title,
|
||||
posterUrl: posterUrl,
|
||||
rating: _item.rating,
|
||||
metaParts: metaParts,
|
||||
genres: genres,
|
||||
overview: _item.description,
|
||||
actions: _buildPlayActions(),
|
||||
),
|
||||
if (_loadingDetail)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 28),
|
||||
child: Center(child: AppLoadingRing(size: 28)),
|
||||
),
|
||||
if (_detailError != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
AppErrorState(
|
||||
compact: true,
|
||||
message: _detailError!,
|
||||
onRetry: _loadDetail,
|
||||
),
|
||||
],
|
||||
if (_item.episodeItems.isNotEmpty || _item.childItems.isNotEmpty) ...[
|
||||
const SizedBox(height: 36),
|
||||
_EpisodeSection(
|
||||
items: _item.episodeItems.isNotEmpty
|
||||
? _item.episodeItems
|
||||
: _item.childItems,
|
||||
onPlay: _playItem,
|
||||
),
|
||||
],
|
||||
if (_item.relatedItems.isNotEmpty) ...[
|
||||
const SizedBox(height: 36),
|
||||
_RelatedSection(widgetId: widget.widgetId, items: _item.relatedItems),
|
||||
],
|
||||
if (!_loadingDetail &&
|
||||
_item.episodeItems.isEmpty &&
|
||||
_item.childItems.isEmpty &&
|
||||
_item.relatedItems.isEmpty &&
|
||||
_item.description.trim().isEmpty) ...[
|
||||
const SizedBox(height: 32),
|
||||
const EmptyState(
|
||||
compact: true,
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: '暂无更多详情',
|
||||
message: '仍可尝试解析并播放该条目。',
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayActions() {
|
||||
return FButton(
|
||||
onPress: _loadingResource ? null : () => _playItem(_item),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_loadingResource)
|
||||
const AppLoadingRing(size: 17)
|
||||
else
|
||||
const Icon(Icons.play_arrow_rounded, size: 20),
|
||||
const SizedBox(width: 7),
|
||||
Text(_loadingResource ? '正在解析' : '播放'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResourcePickerDialog extends StatelessWidget {
|
||||
final List<ScriptVideoResource> resources;
|
||||
|
||||
const _ResourcePickerDialog({required this.resources});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('选择播放线路', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'模块返回了 ${resources.length} 条可播放线路。',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: resources.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final resource = resources[index];
|
||||
return FItemGroup(
|
||||
children: [
|
||||
FItem(
|
||||
prefix: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
resource.name.isEmpty
|
||||
? '线路 ${index + 1}'
|
||||
: resource.name,
|
||||
),
|
||||
subtitle: Text(
|
||||
resource.description.trim().isEmpty
|
||||
? resource.playerType == 'app'
|
||||
? '应用内播放'
|
||||
: '外部播放'
|
||||
: resource.description,
|
||||
),
|
||||
suffix: const Icon(Icons.chevron_right_rounded),
|
||||
onPress: () => Navigator.of(context).pop(resource),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EpisodeSection extends StatelessWidget {
|
||||
final List<ScriptVideoItem> items;
|
||||
final ValueChanged<ScriptVideoItem> onPlay;
|
||||
|
||||
const _EpisodeSection({required this.items, required this.onPlay});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选集',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => onPlay(items[index]),
|
||||
child: Text(
|
||||
items[index].title.isNotEmpty
|
||||
? items[index].title
|
||||
: '第 ${items[index].episode ?? index + 1} 集',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RelatedSection extends StatelessWidget {
|
||||
final String widgetId;
|
||||
final List<ScriptVideoItem> items;
|
||||
|
||||
const _RelatedSection({required this.widgetId, required this.items});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'相关推荐',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: HoverScrollableRow(
|
||||
builder: (context, scrollController) => ListView.separated(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 14),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ScriptVideoCard(
|
||||
item: item,
|
||||
width: 138,
|
||||
textColor: Colors.white,
|
||||
onTap: () => context.push(
|
||||
'/modules/${Uri.encodeComponent(widgetId)}/detail',
|
||||
extra: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScriptVideoItem _mergeSeedWithDetail(
|
||||
ScriptVideoItem seed,
|
||||
ScriptVideoItem detail,
|
||||
) {
|
||||
return detail.copyWith(
|
||||
id: detail.id.isEmpty ? seed.id : detail.id,
|
||||
title: detail.title.isEmpty ? seed.title : detail.title,
|
||||
coverUrl: detail.coverUrl.isEmpty ? seed.coverUrl : detail.coverUrl,
|
||||
posterPath: detail.posterPath.isEmpty ? seed.posterPath : detail.posterPath,
|
||||
detailPoster: detail.detailPoster.isEmpty
|
||||
? seed.detailPoster
|
||||
: detail.detailPoster,
|
||||
backdropPath: detail.backdropPath.isEmpty
|
||||
? seed.backdropPath
|
||||
: detail.backdropPath,
|
||||
link: detail.link.isEmpty ? seed.link : detail.link,
|
||||
videoUrl: detail.videoUrl.isEmpty ? seed.videoUrl : detail.videoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
String? _firstNonEmpty(List<String> values) {
|
||||
for (final value in values) {
|
||||
if (value.trim().isNotEmpty) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/script_widget.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/adaptive_modal.dart';
|
||||
import '../../shared/widgets/app_card.dart';
|
||||
import '../../shared/widgets/app_dialog.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_inline_alert.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_snack_bar.dart';
|
||||
import '../../shared/widgets/auto_dismiss_menu.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/pill_tab_bar.dart';
|
||||
import 'script_widget_parameter_form.dart';
|
||||
|
||||
|
||||
Future<void> showModuleCenterSheet(BuildContext context) {
|
||||
return showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: 720,
|
||||
compactHeightFactor: 0.92,
|
||||
useRootNavigator: true,
|
||||
builder: (sheetContext) => const _ModuleCenterSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ModuleCenterSheet extends StatelessWidget {
|
||||
const _ModuleCenterSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: const ModuleCenterContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> actions;
|
||||
final bool showCloseButton;
|
||||
|
||||
const _SheetHeader({
|
||||
required this.title,
|
||||
required this.actions,
|
||||
this.showCloseButton = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
...actions,
|
||||
if (showCloseButton)
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ModuleCenterContent extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const ModuleCenterContent({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<ModuleCenterContent> createState() =>
|
||||
_ModuleCenterContentState();
|
||||
}
|
||||
|
||||
class _ModuleCenterContentState extends ConsumerState<ModuleCenterContent> {
|
||||
int _selectedSectionIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final installedWidgets = ref.watch(installedScriptWidgetsProvider);
|
||||
final subscriptions = ref.watch(scriptWidgetSubscriptionsProvider);
|
||||
final embedded = widget.embedded;
|
||||
|
||||
final headerActions = <Widget>[
|
||||
IconButton(
|
||||
onPressed: () => _addFromFile(context),
|
||||
icon: const Icon(Icons.file_open_outlined),
|
||||
tooltip: '从本地文件导入',
|
||||
),
|
||||
FButton(
|
||||
onPress: () => _addFromUrl(context),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add_link_rounded, size: 17),
|
||||
SizedBox(width: 6),
|
||||
Text('添加 URL'),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
final headerHorizontalPadding = embedded
|
||||
? const EdgeInsets.fromLTRB(4, 0, 4, 4)
|
||||
: EdgeInsets.zero;
|
||||
final tabPadding = embedded
|
||||
? const EdgeInsets.fromLTRB(4, 8, 4, 4)
|
||||
: const EdgeInsets.fromLTRB(20, 14, 20, 4);
|
||||
final sectionPadding = embedded
|
||||
? const EdgeInsets.fromLTRB(4, 12, 4, 24)
|
||||
: const EdgeInsets.fromLTRB(20, 12, 20, 48);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: headerHorizontalPadding,
|
||||
child: _SheetHeader(
|
||||
title: embedded ? '' : '模块中心',
|
||||
actions: headerActions,
|
||||
showCloseButton: !embedded,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: tabPadding,
|
||||
child: PillTabBar(
|
||||
tabs: const <String>['已安装', '模块订阅'],
|
||||
selectedIndex: _selectedSectionIndex,
|
||||
onSelect: (selectedIndex) {
|
||||
setState(() => _selectedSectionIndex = selectedIndex);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: sectionPadding,
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _selectedSectionIndex == 0
|
||||
? installedWidgets.when(
|
||||
data: (widgets) => _InstalledWidgetsSection(
|
||||
widgets: widgets,
|
||||
onChanged: () => ref.invalidate(
|
||||
installedScriptWidgetsProvider,
|
||||
),
|
||||
),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
error: (error, _) => AppErrorState(
|
||||
compact: true,
|
||||
message: formatUserError(error),
|
||||
onRetry: () => ref.invalidate(
|
||||
installedScriptWidgetsProvider,
|
||||
),
|
||||
),
|
||||
)
|
||||
: subscriptions.when(
|
||||
data: (items) => _SubscriptionsSection(
|
||||
subscriptions: items,
|
||||
onChanged: () {
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
},
|
||||
),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: AppLoadingRing(size: 30)),
|
||||
),
|
||||
error: (error, _) => AppErrorState(
|
||||
compact: true,
|
||||
message: formatUserError(error),
|
||||
onRetry: () => ref.invalidate(
|
||||
scriptWidgetSubscriptionsProvider,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
await Future.wait([
|
||||
ref.read(installedScriptWidgetsProvider.future),
|
||||
ref.read(scriptWidgetSubscriptionsProvider.future),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _addFromUrl(BuildContext context) async {
|
||||
final url = await showModuleUrlImportSheet(context);
|
||||
if (url == null || url.trim().isEmpty || !context.mounted) return;
|
||||
try {
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
if (url.toLowerCase().split('?').first.endsWith('.fwd')) {
|
||||
await service.importSubscription(url.trim());
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
if (context.mounted) showAppToast(context, '模块订阅已添加');
|
||||
} else {
|
||||
final installed = await service.installFromUrl(url.trim());
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '已安装 ${installed.manifest.title}');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(context, formatUserError(error), tone: AppAlertTone.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addFromFile(BuildContext context) async {
|
||||
const acceptedFiles = XTypeGroup(
|
||||
label: 'ForwardWidgets',
|
||||
extensions: <String>['js', 'fwd'],
|
||||
);
|
||||
final selectedFile = await openFile(
|
||||
acceptedTypeGroups: const <XTypeGroup>[acceptedFiles],
|
||||
);
|
||||
if (selectedFile == null || !context.mounted) return;
|
||||
try {
|
||||
final source = await selectedFile.readAsString();
|
||||
if (!context.mounted) return;
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
if (selectedFile.name.toLowerCase().endsWith('.fwd')) {
|
||||
await service.importSubscriptionSource(
|
||||
source,
|
||||
sourceId: 'local:${selectedFile.name}',
|
||||
);
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
if (context.mounted) showAppToast(context, '本地模块订阅已添加');
|
||||
return;
|
||||
}
|
||||
final installed = await service.installFromSource(source);
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '已安装 ${installed.manifest.title}');
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(context, formatUserError(error), tone: AppAlertTone.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InstalledWidgetsSection extends ConsumerWidget {
|
||||
final List<InstalledScriptWidget> widgets;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _InstalledWidgetsSection({
|
||||
required this.widgets,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (widgets.isEmpty) {
|
||||
return const EmptyState(
|
||||
compact: true,
|
||||
icon: Icons.widgets_outlined,
|
||||
title: '尚未安装模块',
|
||||
message: '可导入 ForwardWidgets 的 JS 地址、.fwd 订阅或本地脚本。',
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (var index = 0; index < widgets.length; index++) ...[
|
||||
_InstalledWidgetTile(
|
||||
installedWidget: widgets[index],
|
||||
onOpen: () {
|
||||
final widgetId = widgets[index].manifest.id;
|
||||
|
||||
|
||||
Navigator.of(context).pop();
|
||||
context.go('/modules/${Uri.encodeComponent(widgetId)}');
|
||||
},
|
||||
onToggle: (enabled) async {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.setWidgetEnabled(
|
||||
widgets[index].manifest.id,
|
||||
enabled,
|
||||
);
|
||||
onChanged();
|
||||
},
|
||||
onConfigure: widgets[index].manifest.globalParams.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
final values = await showAppRawDialog<Map<String, Object?>>(
|
||||
context,
|
||||
builder: (_) => _GlobalParametersDialog(
|
||||
parameters: widgets[index].manifest.globalParams,
|
||||
initialValues: widgets[index].globalParams,
|
||||
),
|
||||
);
|
||||
if (values == null || !context.mounted) return;
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.updateGlobalParams(
|
||||
widgets[index].manifest.id,
|
||||
values,
|
||||
);
|
||||
ref.invalidate(
|
||||
installedScriptWidgetProvider(widgets[index].manifest.id),
|
||||
);
|
||||
onChanged();
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '模块参数已保存');
|
||||
}
|
||||
},
|
||||
onRefresh: widgets[index].sourceUrl.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.refreshWidget(widgets[index].manifest.id);
|
||||
onChanged();
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '模块已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(
|
||||
context,
|
||||
formatUserError(error),
|
||||
tone: AppAlertTone.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDelete: () async {
|
||||
final confirmed = await showAppConfirmDialog(
|
||||
context,
|
||||
title: '删除模块',
|
||||
body: Text('确定删除“${widgets[index].manifest.title}”吗?'),
|
||||
confirmLabel: '删除',
|
||||
destructive: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.deleteWidget(widgets[index].manifest.id);
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
if (index < widgets.length - 1) const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstalledWidgetTile extends StatelessWidget {
|
||||
final InstalledScriptWidget installedWidget;
|
||||
final VoidCallback onOpen;
|
||||
final ValueChanged<bool> onToggle;
|
||||
final VoidCallback? onConfigure;
|
||||
final VoidCallback? onRefresh;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _InstalledWidgetTile({
|
||||
required this.installedWidget,
|
||||
required this.onOpen,
|
||||
required this.onToggle,
|
||||
required this.onConfigure,
|
||||
required this.onRefresh,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final manifest = installedWidget.manifest;
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final menuItems = <FItem>[
|
||||
if (onConfigure != null)
|
||||
FItem(
|
||||
prefix: const Icon(Icons.settings_outlined, size: 17),
|
||||
title: const Text('配置全局参数'),
|
||||
onPress: onConfigure,
|
||||
),
|
||||
if (onRefresh != null)
|
||||
FItem(
|
||||
prefix: const Icon(Icons.refresh_rounded, size: 17),
|
||||
title: const Text('检查更新'),
|
||||
onPress: onRefresh,
|
||||
),
|
||||
FItem(
|
||||
prefix: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
size: 17,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
title: Text('删除模块', style: TextStyle(color: theme.colorScheme.error)),
|
||||
onPress: onDelete,
|
||||
),
|
||||
];
|
||||
|
||||
return Opacity(
|
||||
opacity: installedWidget.enabled ? 1 : 0.62,
|
||||
child: AppCard(
|
||||
onTap: installedWidget.enabled ? onOpen : null,
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusCard),
|
||||
),
|
||||
child: Text(
|
||||
manifest.title.isEmpty ? '?' : manifest.title[0],
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
manifest.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
if (manifest.author.isNotEmpty) manifest.author,
|
||||
'v${manifest.version}',
|
||||
'${manifest.modules.length} 个功能',
|
||||
].join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FSwitch(value: installedWidget.enabled, onChange: onToggle),
|
||||
const SizedBox(width: 6),
|
||||
FPopoverMenu(
|
||||
menuBuilder: autoDismissMenuBuilder,
|
||||
menuAnchor: Alignment.topRight,
|
||||
childAnchor: Alignment.bottomRight,
|
||||
menu: [FItemGroup(children: menuItems)],
|
||||
builder: (context, controller, child) =>
|
||||
GestureDetector(onTap: controller.toggle, child: child),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.more_vert_rounded, size: 19),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubscriptionsSection extends ConsumerWidget {
|
||||
final List<ScriptWidgetSubscription> subscriptions;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _SubscriptionsSection({
|
||||
required this.subscriptions,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (subscriptions.isEmpty) {
|
||||
return const EmptyState(
|
||||
compact: true,
|
||||
icon: Icons.inventory_2_outlined,
|
||||
title: '尚未添加订阅',
|
||||
message: '添加 .fwd URL 后,可从仓库中安装和更新模块。',
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (final subscription in subscriptions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: AppCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
title: Text(subscription.catalog.title),
|
||||
subtitle: Text(
|
||||
'${subscription.catalog.widgets.length} 个模块 · ${subscription.url}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: '刷新订阅',
|
||||
onPressed: subscription.url.startsWith('http')
|
||||
? () async {
|
||||
try {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.importSubscription(subscription.url);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(
|
||||
context,
|
||||
formatUserError(error),
|
||||
tone: AppAlertTone.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
children: [
|
||||
for (final entry in subscription.catalog.widgets)
|
||||
_CatalogEntryRow(
|
||||
entry: entry,
|
||||
onInstall: () async {
|
||||
try {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.installFromUrl(entry.url);
|
||||
onChanged();
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '已安装 ${entry.title}');
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(
|
||||
context,
|
||||
formatUserError(error),
|
||||
tone: AppAlertTone.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CatalogEntryRow extends StatelessWidget {
|
||||
final ScriptWidgetCatalogEntry entry;
|
||||
final VoidCallback onInstall;
|
||||
|
||||
const _CatalogEntryRow({required this.entry, required this.onInstall});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
entry.description.isNotEmpty
|
||||
? entry.description
|
||||
: '${entry.author} · v${entry.version}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: onInstall,
|
||||
child: const Text('安装'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlobalParametersDialog extends StatefulWidget {
|
||||
final List<ScriptWidgetParam> parameters;
|
||||
final Map<String, Object?> initialValues;
|
||||
|
||||
const _GlobalParametersDialog({
|
||||
required this.parameters,
|
||||
required this.initialValues,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_GlobalParametersDialog> createState() =>
|
||||
_GlobalParametersDialogState();
|
||||
}
|
||||
|
||||
class _GlobalParametersDialogState extends State<_GlobalParametersDialog> {
|
||||
late Map<String, Object?> _values;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_values = <String, Object?>{
|
||||
...buildScriptWidgetParameterDefaults(widget.parameters),
|
||||
...widget.initialValues,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('全局参数', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const Text('这些值会自动合并到该模块的每次调用中。'),
|
||||
const SizedBox(height: 18),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 440),
|
||||
child: SingleChildScrollView(
|
||||
child: ScriptWidgetParameterForm(
|
||||
parameters: widget.parameters,
|
||||
initialValues: _values,
|
||||
onChanged: (values) {
|
||||
_values = Map<String, Object?>.from(values);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FButton(
|
||||
onPress: () => Navigator.of(context).pop(_values),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> showModuleUrlImportSheet(BuildContext context) {
|
||||
return showAdaptiveModal<String>(
|
||||
context: context,
|
||||
maxWidth: 520,
|
||||
useRootNavigator: true,
|
||||
compactMaxHeightFactor: 0.6,
|
||||
builder: (sheetContext) => const _UrlImportSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _UrlImportSheet extends StatefulWidget {
|
||||
const _UrlImportSheet();
|
||||
|
||||
@override
|
||||
State<_UrlImportSheet> createState() => _UrlImportSheetState();
|
||||
}
|
||||
|
||||
class _UrlImportSheetState extends State<_UrlImportSheet> {
|
||||
final TextEditingController _urlController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final inputBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: tokens.mutedForeground.withValues(alpha: 0.28),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('添加模块', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const Text('支持单个 .js 脚本地址或 ForwardWidgets .fwd 订阅地址。'),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: _urlController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'HTTP(S) URL',
|
||||
hintText: 'https://example.com/widgets.fwd',
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surface.withValues(alpha: 0.42),
|
||||
border: inputBorder,
|
||||
enabledBorder: inputBorder,
|
||||
focusedBorder: inputBorder.copyWith(
|
||||
borderSide: BorderSide(color: theme.colorScheme.primary),
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FButton(onPress: _submit, child: const Text('继续')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final url = _urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
Navigator.of(context).pop(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/contracts/script_widget.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/widgets/setting_select.dart';
|
||||
|
||||
class ScriptWidgetParameterForm extends StatefulWidget {
|
||||
final List<ScriptWidgetParam> parameters;
|
||||
final Map<String, Object?> initialValues;
|
||||
final ValueChanged<Map<String, Object?>> onChanged;
|
||||
final bool hidePagingParameters;
|
||||
|
||||
const ScriptWidgetParameterForm({
|
||||
super.key,
|
||||
required this.parameters,
|
||||
required this.initialValues,
|
||||
required this.onChanged,
|
||||
this.hidePagingParameters = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScriptWidgetParameterForm> createState() =>
|
||||
_ScriptWidgetParameterFormState();
|
||||
}
|
||||
|
||||
class _ScriptWidgetParameterFormState extends State<ScriptWidgetParameterForm> {
|
||||
final Map<String, TextEditingController> _textControllers =
|
||||
<String, TextEditingController>{};
|
||||
late Map<String, Object?> _values;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_values = _buildInitialValues();
|
||||
_synchronizeTextControllers();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ScriptWidgetParameterForm oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.parameters != widget.parameters ||
|
||||
oldWidget.initialValues != widget.initialValues) {
|
||||
_values = _buildInitialValues();
|
||||
_synchronizeTextControllers();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _textControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, Object?> _buildInitialValues() {
|
||||
final defaultValues = buildScriptWidgetParameterDefaults(widget.parameters);
|
||||
return <String, Object?>{
|
||||
for (final parameter in widget.parameters)
|
||||
parameter.name:
|
||||
widget.initialValues[parameter.name] ??
|
||||
defaultValues[parameter.name],
|
||||
};
|
||||
}
|
||||
|
||||
void _synchronizeTextControllers() {
|
||||
final activeNames = widget.parameters
|
||||
.where((parameter) => !_usesOptionSelector(parameter))
|
||||
.map((parameter) => parameter.name)
|
||||
.toSet();
|
||||
final staleNames = _textControllers.keys
|
||||
.where((name) => !activeNames.contains(name))
|
||||
.toList(growable: false);
|
||||
for (final name in staleNames) {
|
||||
_textControllers.remove(name)?.dispose();
|
||||
}
|
||||
for (final parameter in widget.parameters) {
|
||||
if (_usesOptionSelector(parameter)) continue;
|
||||
final value = _values[parameter.name]?.toString() ?? '';
|
||||
final controller = _textControllers.putIfAbsent(
|
||||
parameter.name,
|
||||
TextEditingController.new,
|
||||
);
|
||||
if (controller.text != value) {
|
||||
controller.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _usesOptionSelector(ScriptWidgetParam parameter) {
|
||||
return parameter.type == 'enumeration' ||
|
||||
parameter.enumOptions.isNotEmpty ||
|
||||
parameter.placeholders.isNotEmpty;
|
||||
}
|
||||
|
||||
bool _isVisible(ScriptWidgetParam parameter) {
|
||||
if (parameter.type == 'constant') return false;
|
||||
if (widget.hidePagingParameters &&
|
||||
const <String>{'page', 'offset'}.contains(parameter.type)) {
|
||||
return false;
|
||||
}
|
||||
final condition = parameter.belongTo;
|
||||
if (condition == null || condition.paramName.isEmpty) return true;
|
||||
final parentValue = _values[condition.paramName]?.toString() ?? '';
|
||||
return condition.value.contains(parentValue);
|
||||
}
|
||||
|
||||
void _updateValue(String name, Object? value) {
|
||||
setState(() {
|
||||
_values[name] = value;
|
||||
});
|
||||
widget.onChanged(Map<String, Object?>.unmodifiable(_values));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visibleParameters = widget.parameters
|
||||
.where(_isVisible)
|
||||
.toList(growable: false);
|
||||
if (visibleParameters.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final parameter in visibleParameters)
|
||||
SizedBox(
|
||||
width: _fieldWidth(parameter),
|
||||
child: _buildField(parameter),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
double _fieldWidth(ScriptWidgetParam parameter) {
|
||||
if (const <String>{'count', 'page', 'offset'}.contains(parameter.type)) {
|
||||
return 132;
|
||||
}
|
||||
return 240;
|
||||
}
|
||||
|
||||
Widget _buildField(ScriptWidgetParam parameter) {
|
||||
final options = parameter.enumOptions.isNotEmpty
|
||||
? parameter.enumOptions
|
||||
: parameter.placeholders;
|
||||
if (_usesOptionSelector(parameter)) {
|
||||
final selectedValue = _values[parameter.name]?.toString() ?? '';
|
||||
final optionValues = options.map((option) => option.value).toSet();
|
||||
final effectiveValue = optionValues.contains(selectedValue)
|
||||
? selectedValue
|
||||
: null;
|
||||
final labelsByValue = <String, String>{
|
||||
for (final option in options)
|
||||
option.value: option.title.isEmpty ? option.value : option.title,
|
||||
};
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final helper = _helperFor(parameter);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_labelFor(parameter),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SettingSelect<String>(
|
||||
value: effectiveValue,
|
||||
options: options
|
||||
.map((option) => option.value)
|
||||
.toList(growable: false),
|
||||
labelOf: (value) => labelsByValue[value] ?? value,
|
||||
onChanged: (value) => _updateValue(parameter.name, value ?? ''),
|
||||
),
|
||||
if (helper != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
helper,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final isNumeric = const <String>{
|
||||
'count',
|
||||
'page',
|
||||
'offset',
|
||||
}.contains(parameter.type);
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final inputBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: tokens.mutedForeground.withValues(alpha: 0.28),
|
||||
),
|
||||
);
|
||||
return TextField(
|
||||
controller: _textControllers[parameter.name],
|
||||
keyboardType: isNumeric ? TextInputType.number : TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
labelText: _labelFor(parameter),
|
||||
helperText: _helperFor(parameter),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surface.withValues(alpha: 0.42),
|
||||
border: inputBorder,
|
||||
enabledBorder: inputBorder,
|
||||
focusedBorder: inputBorder.copyWith(
|
||||
borderSide: BorderSide(color: theme.colorScheme.primary),
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
final parsedValue = isNumeric ? int.tryParse(value) ?? value : value;
|
||||
_updateValue(parameter.name, parsedValue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _labelFor(ScriptWidgetParam parameter) {
|
||||
return parameter.title.isEmpty ? parameter.name : parameter.title;
|
||||
}
|
||||
|
||||
String? _helperFor(ScriptWidgetParam parameter) {
|
||||
final description = parameter.description.trim();
|
||||
return description.isEmpty ? null : description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/auth.dart';
|
||||
import '../../core/contracts/library.dart';
|
||||
import '../../providers/emby_headers_provider.dart';
|
||||
import '../../providers/person_items_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/constants/layout_constants.dart';
|
||||
import '../../shared/mappers/media_image_url.dart';
|
||||
import '../../shared/utils/media_nav.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/media_poster_card.dart';
|
||||
|
||||
|
||||
class PersonDetailPage extends ConsumerWidget {
|
||||
final String embyPersonId;
|
||||
|
||||
const PersonDetailPage({super.key, required this.embyPersonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(activeSessionProvider);
|
||||
if (session == null) {
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader(title: '演员', leading: _buildBackButton(context)),
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _EmptyState(message: '请先登录服务器'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final asyncDetail = ref.watch(personItemDetailProvider(embyPersonId));
|
||||
final asyncItems = ref.watch(personItemsProvider(embyPersonId));
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
|
||||
final personName = asyncDetail.value?.Name;
|
||||
final title = (personName != null && personName.isNotEmpty)
|
||||
? '演员 $personName 的全部作品'
|
||||
: '演员';
|
||||
|
||||
final leadingAvatar = _personAvatar(
|
||||
context: context,
|
||||
session: session,
|
||||
person: asyncDetail.value,
|
||||
headers: imageHeaders,
|
||||
);
|
||||
|
||||
final Widget titleWidget = Row(
|
||||
children: [
|
||||
if (leadingAvatar != null) ...[
|
||||
leadingAvatar,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final Widget contentSliver = asyncItems.when(
|
||||
loading: () => const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
error: (_, _) => SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: AppErrorState(
|
||||
title: '查询失败',
|
||||
onRetry: () => ref.invalidate(personItemsProvider(embyPersonId)),
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(icon: Icons.movie_filter_outlined, title: '暂无作品'),
|
||||
);
|
||||
}
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: mediaGridDelegate,
|
||||
delegate: SliverChildBuilderDelegate((_, i) {
|
||||
final item = items[i];
|
||||
return MediaPosterCard(
|
||||
item: item,
|
||||
width: double.infinity,
|
||||
imageUrl: EmbyImageUrl.primary(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: item,
|
||||
maxHeight: 360,
|
||||
),
|
||||
preloadImageUrl: EmbyImageUrl.detailBanner(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
item: item,
|
||||
),
|
||||
imageHeaders: imageHeaders,
|
||||
onTap: () => goMediaDetail(context, item, ref: ref),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
titleWidget: titleWidget,
|
||||
leading: _buildBackButton(context),
|
||||
),
|
||||
contentSliver,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButton(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _personAvatar({
|
||||
required BuildContext context,
|
||||
required AuthedSession session,
|
||||
required EmbyRawItem? person,
|
||||
required Map<String, String>? headers,
|
||||
}) {
|
||||
if (person == null) return null;
|
||||
final hasImage =
|
||||
(person.PrimaryImageTag != null) ||
|
||||
(person.ImageTags?['Primary'] != null);
|
||||
if (!hasImage) return null;
|
||||
final url = EmbyImageUrl.primaryById(
|
||||
baseUrl: session.serverUrl,
|
||||
token: session.token,
|
||||
itemId: person.Id,
|
||||
maxHeight: 80,
|
||||
);
|
||||
return CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
backgroundImage: ResizeImage.resizeIfNeeded(
|
||||
96,
|
||||
null,
|
||||
CachedNetworkImageProvider(url, headers: headers),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final String message;
|
||||
const _EmptyState({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import '../../../core/contracts/player_gestures.dart';
|
||||
|
||||
export '../../../core/contracts/player_gestures.dart' show GestureAction;
|
||||
|
||||
String gestureActionLabel(GestureAction a) => switch (a) {
|
||||
GestureAction.none => '无操作',
|
||||
GestureAction.playPause => '播放/暂停',
|
||||
GestureAction.fullscreen => '全屏',
|
||||
GestureAction.toggleControls => '显示/隐藏控制栏',
|
||||
GestureAction.seekBackward => '后退',
|
||||
GestureAction.seekForward => '前进',
|
||||
GestureAction.previousItem => '上一个',
|
||||
GestureAction.nextItem => '下一个',
|
||||
GestureAction.exit => '退出',
|
||||
};
|
||||
|
||||
enum GestureKind { leftClick, leftDoubleClick, rightClick, rightDoubleClick }
|
||||
|
||||
|
||||
List<GestureAction> allowedActionsFor(GestureKind kind) {
|
||||
switch (kind) {
|
||||
case GestureKind.leftClick:
|
||||
case GestureKind.rightClick:
|
||||
return const [
|
||||
GestureAction.playPause,
|
||||
GestureAction.toggleControls,
|
||||
GestureAction.none,
|
||||
];
|
||||
case GestureKind.leftDoubleClick:
|
||||
case GestureKind.rightDoubleClick:
|
||||
return const [
|
||||
GestureAction.playPause,
|
||||
GestureAction.fullscreen,
|
||||
GestureAction.seekBackward,
|
||||
GestureAction.seekForward,
|
||||
GestureAction.previousItem,
|
||||
GestureAction.nextItem,
|
||||
GestureAction.exit,
|
||||
GestureAction.none,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'gesture_action.dart';
|
||||
|
||||
class GestureBindings {
|
||||
final double keyboardHoldLeftSpeed;
|
||||
final double keyboardHoldRightSpeed;
|
||||
final GestureAction mouseLeftClickAction;
|
||||
final GestureAction mouseLeftDoubleClickAction;
|
||||
final GestureAction mouseRightClickAction;
|
||||
final GestureAction mouseRightDoubleClickAction;
|
||||
|
||||
const GestureBindings({
|
||||
required this.keyboardHoldLeftSpeed,
|
||||
required this.keyboardHoldRightSpeed,
|
||||
required this.mouseLeftClickAction,
|
||||
required this.mouseLeftDoubleClickAction,
|
||||
required this.mouseRightClickAction,
|
||||
required this.mouseRightDoubleClickAction,
|
||||
});
|
||||
|
||||
static const defaults = GestureBindings(
|
||||
keyboardHoldLeftSpeed: 0.5,
|
||||
keyboardHoldRightSpeed: 3.0,
|
||||
mouseLeftClickAction: GestureAction.playPause,
|
||||
mouseLeftDoubleClickAction: GestureAction.fullscreen,
|
||||
mouseRightClickAction: GestureAction.toggleControls,
|
||||
mouseRightDoubleClickAction: GestureAction.none,
|
||||
);
|
||||
|
||||
GestureAction actionFor(GestureKind kind) => switch (kind) {
|
||||
GestureKind.leftClick => mouseLeftClickAction,
|
||||
GestureKind.leftDoubleClick => mouseLeftDoubleClickAction,
|
||||
GestureKind.rightClick => mouseRightClickAction,
|
||||
GestureKind.rightDoubleClick => mouseRightDoubleClickAction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../shared/utils/app_logger.dart';
|
||||
import 'playlist/cross_server_playlist.dart';
|
||||
import 'playlist/pending_cross_server_sequence_provider.dart';
|
||||
|
||||
|
||||
class PlayerExitResult {
|
||||
final int? positionTicks;
|
||||
final String serverId;
|
||||
final String itemId;
|
||||
final String? mediaSourceId;
|
||||
|
||||
const PlayerExitResult({
|
||||
required this.positionTicks,
|
||||
required this.serverId,
|
||||
required this.itemId,
|
||||
required this.mediaSourceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class DirectStreamLaunchData {
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
final String title;
|
||||
final int? durationSeconds;
|
||||
final String? seriesName;
|
||||
final int? episode;
|
||||
|
||||
const DirectStreamLaunchData({
|
||||
required this.url,
|
||||
this.headers = const <String, String>{},
|
||||
required this.title,
|
||||
this.durationSeconds,
|
||||
this.seriesName,
|
||||
this.episode,
|
||||
});
|
||||
}
|
||||
|
||||
class PlayerLauncher {
|
||||
static const _tag = 'PlayerLauncher';
|
||||
|
||||
|
||||
static Future<PlayerExitResult?> launch({
|
||||
required BuildContext context,
|
||||
required String itemId,
|
||||
String? serverId,
|
||||
String? mediaSourceId,
|
||||
int preferredSubtitleStreamIndex = -1,
|
||||
int? preferredAudioStreamIndex,
|
||||
int? startPositionTicks,
|
||||
bool fromStart = false,
|
||||
String? backdropUrl,
|
||||
WidgetRef? ref,
|
||||
List<CrossServerPlaylistEntry>? crossServerSequence,
|
||||
DirectStreamLaunchData? directStream,
|
||||
}) async {
|
||||
if (crossServerSequence != null && crossServerSequence.isNotEmpty) {
|
||||
assert(
|
||||
ref != null,
|
||||
'PlayerLauncher.launch: ref is required when crossServerSequence is provided',
|
||||
);
|
||||
ref!.read(pendingCrossServerSequenceProvider.notifier).state =
|
||||
List.unmodifiable(crossServerSequence);
|
||||
}
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'launch serverId=$serverId itemId=$itemId mediaSourceId=$mediaSourceId '
|
||||
'subtitleIndex=$preferredSubtitleStreamIndex '
|
||||
'audioIndex=$preferredAudioStreamIndex '
|
||||
'startPositionTicks=$startPositionTicks '
|
||||
'fromStart=$fromStart '
|
||||
'hasBackdrop=${backdropUrl?.isNotEmpty ?? false} '
|
||||
'directStream=${directStream != null} '
|
||||
'crossServerSequence=${crossServerSequence?.length ?? 0}',
|
||||
);
|
||||
|
||||
final queryParams = <String, String>{};
|
||||
if (serverId != null && serverId.isNotEmpty) {
|
||||
queryParams['serverId'] = serverId;
|
||||
}
|
||||
if (mediaSourceId != null) queryParams['mediaSourceId'] = mediaSourceId;
|
||||
if (backdropUrl != null && backdropUrl.isNotEmpty) {
|
||||
queryParams['backdropUrl'] = backdropUrl;
|
||||
}
|
||||
queryParams['subtitleIndex'] = '$preferredSubtitleStreamIndex';
|
||||
if (preferredAudioStreamIndex != null) {
|
||||
queryParams['audioIndex'] = '$preferredAudioStreamIndex';
|
||||
}
|
||||
if (fromStart) {
|
||||
queryParams['fromStart'] = '1';
|
||||
} else if (startPositionTicks != null && startPositionTicks > 0) {
|
||||
queryParams['startPositionTicks'] = '$startPositionTicks';
|
||||
}
|
||||
final uri = Uri(
|
||||
path: '/player/$itemId',
|
||||
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||||
);
|
||||
AppLogger.debug(_tag, 'navigating to: ${uri.toString()}');
|
||||
|
||||
OverlayEntry? androidLaunchCover;
|
||||
void removeAndroidLaunchCover() {
|
||||
final launchCover = androidLaunchCover;
|
||||
androidLaunchCover = null;
|
||||
launchCover?.remove();
|
||||
}
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final playerView = View.of(context);
|
||||
androidLaunchCover = _insertAndroidLaunchCover(context);
|
||||
if (androidLaunchCover != null) {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!context.mounted) return null;
|
||||
}
|
||||
|
||||
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
if (!context.mounted) return null;
|
||||
|
||||
await _waitForAndroidLandscape(playerView);
|
||||
if (!context.mounted) return null;
|
||||
}
|
||||
|
||||
final playerResult = context.push<PlayerExitResult>(
|
||||
uri.toString(),
|
||||
extra: directStream,
|
||||
);
|
||||
if (androidLaunchCover != null) {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
removeAndroidLaunchCover();
|
||||
}
|
||||
return await playerResult;
|
||||
} finally {
|
||||
removeAndroidLaunchCover();
|
||||
if (Platform.isAndroid) {
|
||||
await SystemChrome.setPreferredOrientations(
|
||||
const <DeviceOrientation>[],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static OverlayEntry? _insertAndroidLaunchCover(BuildContext context) {
|
||||
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
||||
if (overlay == null) return null;
|
||||
|
||||
final launchCover = OverlayEntry(
|
||||
builder: (context) => const Positioned.fill(
|
||||
child: IgnorePointer(child: ColoredBox(color: Color(0xFF08090C))),
|
||||
),
|
||||
);
|
||||
overlay.insert(launchCover);
|
||||
return launchCover;
|
||||
}
|
||||
|
||||
static Future<void> _waitForAndroidLandscape(
|
||||
ui.FlutterView playerView,
|
||||
) async {
|
||||
const pollInterval = Duration(milliseconds: 16);
|
||||
final deadline = DateTime.now().add(const Duration(milliseconds: 500));
|
||||
|
||||
while (true) {
|
||||
final windowSize = playerView.physicalSize;
|
||||
if (windowSize.width > windowSize.height) return;
|
||||
if (DateTime.now().isAfter(deadline)) return;
|
||||
await Future<void>.delayed(pollInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _AndroidChrome on _PlayerPageState {
|
||||
static const _androidSpeeds = [
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.25,
|
||||
1.5,
|
||||
1.75,
|
||||
2.0,
|
||||
2.5,
|
||||
3.0,
|
||||
];
|
||||
|
||||
void _seekBy(int seconds) {
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
final target = session.engine.state.position + Duration(seconds: seconds);
|
||||
unawaited(
|
||||
session.engine.seek(target < Duration.zero ? Duration.zero : target),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAndroidSpeedIncrement() {
|
||||
final idx = _androidSpeeds.indexWhere((s) => s > _playbackRate);
|
||||
if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]);
|
||||
}
|
||||
|
||||
void _onAndroidSpeedDecrement() {
|
||||
final idx = _androidSpeeds.lastIndexWhere((s) => s < _playbackRate);
|
||||
if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]);
|
||||
}
|
||||
|
||||
List<Widget> _buildAndroidTopBar(PlaybackSession session) {
|
||||
return [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: _goBack,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_buildTitleText(session),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
QualityBadge(quality: _activeQualityInfo(session)),
|
||||
const SizedBox(width: 8),
|
||||
NetworkSpeedIndicator(monitor: session.speedMonitor),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const PlayerClockText(),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'fit:contain':
|
||||
_setVideoFit(BoxFit.contain);
|
||||
case 'fit:fill':
|
||||
_setVideoFit(BoxFit.fill);
|
||||
case 'fit:cover':
|
||||
_setVideoFit(BoxFit.cover);
|
||||
case 'pip':
|
||||
unawaited(_enterAndroidPip());
|
||||
case 'debug_hud':
|
||||
_debugHudVisible.value = !_debugHudVisible.value;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
CheckedPopupMenuItem<String>(
|
||||
value: 'fit:contain',
|
||||
checked: _videoFit.value == BoxFit.contain,
|
||||
child: const Text('画面比例 - 适应'),
|
||||
),
|
||||
CheckedPopupMenuItem<String>(
|
||||
value: 'fit:fill',
|
||||
checked: _videoFit.value == BoxFit.fill,
|
||||
child: const Text('画面比例 - 拉伸'),
|
||||
),
|
||||
CheckedPopupMenuItem<String>(
|
||||
value: 'fit:cover',
|
||||
checked: _videoFit.value == BoxFit.cover,
|
||||
child: const Text('画面比例 - 裁切'),
|
||||
),
|
||||
if (_androidPip.isSupported && !_isAndroidLocked)
|
||||
const PopupMenuItem<String>(value: 'pip', child: Text('画中画')),
|
||||
if (kDebugMode)
|
||||
CheckedPopupMenuItem<String>(
|
||||
value: 'debug_hud',
|
||||
checked: _debugHudVisible.value,
|
||||
child: const Text('调试 HUD'),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildAndroidBottomBar(PlaybackSession session) {
|
||||
final seekSettings = ref.read(playerSettingsProvider).value;
|
||||
final seekFwd = seekSettings?.seekForwardSeconds ?? 15;
|
||||
final seekBwd = seekSettings?.seekBackwardSeconds ?? 15;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_AndroidTimeLabel(
|
||||
player: session.engine,
|
||||
showPosition: true,
|
||||
seekPreview: _seekPreview,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DebouncedSeekBar(
|
||||
player: session.engine,
|
||||
onDragChanged: (t) => _seekPreview.value = t,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_AndroidTimeLabel(
|
||||
player: session.engine,
|
||||
showPosition: false,
|
||||
seekPreview: _seekPreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final hasVersionSwitch =
|
||||
session.context.allMediaSources.length > 1 ||
|
||||
_crossServerCards.isNotEmpty;
|
||||
final hasEpisode = session.context.seriesId != null;
|
||||
|
||||
const alwaysVisibleWidth = 120.0 + 96.0 + 96.0;
|
||||
|
||||
final secondaryMenuEntries = <PopupMenuEntry<VoidCallback>>[
|
||||
if (hasVersionSwitch)
|
||||
PopupMenuItem<VoidCallback>(
|
||||
value: _toggleVersionBar,
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.switch_video),
|
||||
title: Text('切换版本'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
PopupMenuItem<VoidCallback>(
|
||||
value: () => _togglePanel(PlayerPanelCategory.danmaku),
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.comment_outlined),
|
||||
title: Text('弹幕'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
PopupMenuItem<VoidCallback>(
|
||||
value: () => _togglePanel(PlayerPanelCategory.speed),
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.speed),
|
||||
title: Text('倍速'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
if (hasEpisode)
|
||||
PopupMenuItem<VoidCallback>(
|
||||
value: () => _togglePanel(PlayerPanelCategory.episode),
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.playlist_play),
|
||||
title: Text('选集'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
final secondaryInlineCount =
|
||||
(hasVersionSwitch ? 1 : 0) + 2 + (hasEpisode ? 1 : 0);
|
||||
const iconButtonWidth = 48.0;
|
||||
final overflowMenuButtonWidth = secondaryMenuEntries.isNotEmpty
|
||||
? iconButtonWidth
|
||||
: 0.0;
|
||||
final showAllInline =
|
||||
constraints.maxWidth >=
|
||||
alwaysVisibleWidth +
|
||||
secondaryInlineCount * iconButtonWidth +
|
||||
overflowMenuButtonWidth;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
AndroidBottomTransportControls(
|
||||
player: session.engine,
|
||||
hasPrev: _playlist?.hasPrev ?? false,
|
||||
hasNext: _playlist?.hasNext ?? false,
|
||||
onPrevItem: () => unawaited(_playlist!.prev()),
|
||||
onNextItem: () => unawaited(_playlist!.next()),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '快退',
|
||||
icon: const Icon(
|
||||
Icons.fast_rewind,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () => _seekBy(-seekBwd),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '快进',
|
||||
icon: const Icon(
|
||||
Icons.fast_forward,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () => _seekBy(seekFwd),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showAllInline) ...[
|
||||
if (hasVersionSwitch)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.switch_video,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: _toggleVersionBar,
|
||||
tooltip: '切换版本',
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.subtitles_outlined,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () => _togglePanel(PlayerPanelCategory.subtitle),
|
||||
tooltip: '字幕',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.audiotrack,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () => _togglePanel(PlayerPanelCategory.audio),
|
||||
tooltip: '音轨',
|
||||
),
|
||||
if (showAllInline) ...[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.comment_outlined,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () =>
|
||||
_togglePanel(PlayerPanelCategory.danmaku),
|
||||
tooltip: '弹幕',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.speed,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () => _togglePanel(PlayerPanelCategory.speed),
|
||||
tooltip: '倍速',
|
||||
),
|
||||
if (hasEpisode)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.playlist_play,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () =>
|
||||
_togglePanel(PlayerPanelCategory.episode),
|
||||
tooltip: '选集',
|
||||
),
|
||||
] else if (secondaryMenuEntries.isNotEmpty)
|
||||
PopupMenuButton<VoidCallback>(
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
tooltip: '更多',
|
||||
onSelected: (callback) => callback(),
|
||||
itemBuilder: (_) => secondaryMenuEntries,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAndroidLeftBar() {
|
||||
return AndroidLockButton(onLock: () => _setAndroidLocked(true));
|
||||
}
|
||||
|
||||
Widget _buildAndroidRightBar(PlaybackSession session) {
|
||||
return AndroidSpeedStrip(
|
||||
currentRate: _playbackRate,
|
||||
onIncrement: _onAndroidSpeedIncrement,
|
||||
onDecrement: _onAndroidSpeedDecrement,
|
||||
onTapRate: () => _togglePanel(PlayerPanelCategory.speed),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAndroidGestureLayer(
|
||||
PlaybackSession session,
|
||||
VoidCallback toggleControls,
|
||||
VoidCallback showControls,
|
||||
) {
|
||||
return AndroidGestureLayer(
|
||||
enabled: !_isAndroidLocked,
|
||||
player: session.engine,
|
||||
onToggleControls: toggleControls,
|
||||
onDoubleTapCenter: () {
|
||||
unawaited(
|
||||
session.engine.state.playing
|
||||
? session.engine.pause()
|
||||
: session.engine.play(),
|
||||
);
|
||||
showControls();
|
||||
},
|
||||
onDoubleTapLeft: () {
|
||||
final secs =
|
||||
ref.read(playerSettingsProvider).value?.seekBackwardSeconds ?? 15;
|
||||
_seekBy(-secs);
|
||||
setState(
|
||||
() => _androidFeedbackData = GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.doubleTapSeek,
|
||||
doubleTapSeconds: secs,
|
||||
doubleTapIsLeft: true,
|
||||
),
|
||||
);
|
||||
Future.delayed(const Duration(milliseconds: 600), () {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _androidFeedbackData = const GestureFeedbackData.none(),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
onDoubleTapRight: () {
|
||||
final secs =
|
||||
ref.read(playerSettingsProvider).value?.seekForwardSeconds ?? 15;
|
||||
_seekBy(secs);
|
||||
setState(
|
||||
() => _androidFeedbackData = GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.doubleTapSeek,
|
||||
doubleTapSeconds: secs,
|
||||
doubleTapIsLeft: false,
|
||||
),
|
||||
);
|
||||
Future.delayed(const Duration(milliseconds: 600), () {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _androidFeedbackData = const GestureFeedbackData.none(),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
onBrightnessChange: (delta) async {
|
||||
final gen = _brightnessGen;
|
||||
try {
|
||||
final current = await ScreenBrightness.instance.application;
|
||||
if (!mounted || gen != _brightnessGen) return;
|
||||
final next = (current + delta).clamp(0.0, 1.0);
|
||||
await ScreenBrightness.instance.setApplicationScreenBrightness(next);
|
||||
if (!mounted || gen != _brightnessGen) return;
|
||||
_lastSetBrightness = next;
|
||||
setState(
|
||||
() => _androidFeedbackData = GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.brightness,
|
||||
value: next,
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
},
|
||||
onVolumeChange: (delta) {
|
||||
final oldStep = (_volumeFraction * _maxVolumeLevel).round();
|
||||
_volumeFraction = (_volumeFraction + delta).clamp(0.0, 1.0);
|
||||
final newStep = (_volumeFraction * _maxVolumeLevel).round();
|
||||
if (newStep != oldStep) {
|
||||
unawaited(
|
||||
SystemAudioController.instance.adjustVolume(newStep - oldStep),
|
||||
);
|
||||
}
|
||||
_systemVolumeOsdTimer?.cancel();
|
||||
setState(
|
||||
() => _androidFeedbackData = GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.volume,
|
||||
value: _volumeFraction,
|
||||
),
|
||||
);
|
||||
},
|
||||
onVerticalDragEnd: () {
|
||||
_brightnessGen++;
|
||||
if (_lastSetBrightness != null) {
|
||||
unawaited(
|
||||
_playerSettingsNotifier.setAndroidBrightness(_lastSetBrightness!),
|
||||
);
|
||||
_lastSetBrightness = null;
|
||||
}
|
||||
setState(() => _androidFeedbackData = const GestureFeedbackData.none());
|
||||
},
|
||||
onHorizontalSeekStart: () {},
|
||||
onHorizontalSeekUpdate: (seekDelta) {
|
||||
final current = session.engine.state.position;
|
||||
final duration = session.engine.state.duration;
|
||||
final target = current + seekDelta;
|
||||
final clamped = target < Duration.zero
|
||||
? Duration.zero
|
||||
: (target > duration ? duration : target);
|
||||
_seekPreview.value = clamped;
|
||||
setState(
|
||||
() => _androidFeedbackData = GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.seek,
|
||||
seekTarget: clamped,
|
||||
totalDuration: duration,
|
||||
seekIsForward: !seekDelta.isNegative,
|
||||
),
|
||||
);
|
||||
},
|
||||
onHorizontalSeekEnd: () {
|
||||
final target = _seekPreview.value;
|
||||
if (target != null) {
|
||||
unawaited(session.engine.seek(target));
|
||||
}
|
||||
_seekPreview.value = null;
|
||||
setState(() => _androidFeedbackData = const GestureFeedbackData.none());
|
||||
},
|
||||
onLongPressStart: (isLeftSide) {
|
||||
final settings = ref.read(playerSettingsProvider).value;
|
||||
final holdSpeed = isLeftSide
|
||||
? (settings?.keyboardHoldLeftSpeed ?? 0.5)
|
||||
: (settings?.keyboardHoldRightSpeed ?? 3.0);
|
||||
_savedRateBeforeLongPress = _playbackRate;
|
||||
_setPlaybackRate(holdSpeed);
|
||||
setState(
|
||||
() => _androidFeedbackData = GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.longPressSpeed,
|
||||
speedRate: holdSpeed,
|
||||
),
|
||||
);
|
||||
},
|
||||
onLongPressEnd: () {
|
||||
final restore = _savedRateBeforeLongPress ?? 1.0;
|
||||
_savedRateBeforeLongPress = null;
|
||||
_setPlaybackRate(restore);
|
||||
if (_androidFeedbackData.kind == GestureFeedbackKind.longPressSpeed) {
|
||||
setState(
|
||||
() => _androidFeedbackData = const GestureFeedbackData.none(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAndroidGestureFeedback() {
|
||||
return AndroidGestureFeedback(data: _androidFeedbackData);
|
||||
}
|
||||
|
||||
Widget _buildAndroidDrawerLayer(PlaybackSession session) {
|
||||
return AnimatedBuilder(
|
||||
animation: _drawer,
|
||||
builder: (context, _) {
|
||||
final category = _drawer.category;
|
||||
if (category == null) return const SizedBox.shrink();
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _drawer.close,
|
||||
child: const ColoredBox(
|
||||
color: Colors.black38,
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 320,
|
||||
child: Material(
|
||||
color: const Color(0xF0181818),
|
||||
child: PlayerPanelShell(
|
||||
icon: panelCategoryIcon(category),
|
||||
title: panelCategoryLabel(category),
|
||||
width: double.infinity,
|
||||
onClose: _drawer.close,
|
||||
child: _panelBody(session, category),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AndroidTimeLabel extends StatefulWidget {
|
||||
const _AndroidTimeLabel({
|
||||
required this.player,
|
||||
required this.showPosition,
|
||||
required this.seekPreview,
|
||||
});
|
||||
|
||||
final PlayerEngine player;
|
||||
final bool showPosition;
|
||||
final ValueNotifier<Duration?> seekPreview;
|
||||
|
||||
@override
|
||||
State<_AndroidTimeLabel> createState() => _AndroidTimeLabelState();
|
||||
}
|
||||
|
||||
class _AndroidTimeLabelState extends State<_AndroidTimeLabel> {
|
||||
late Duration _position;
|
||||
late Duration _duration;
|
||||
StreamSubscription<Duration>? _posSub;
|
||||
StreamSubscription<Duration>? _durSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_position = widget.player.state.position;
|
||||
_duration = widget.player.state.duration;
|
||||
_posSub = widget.player.stream.position.listen((p) {
|
||||
if (mounted) setState(() => _position = p);
|
||||
});
|
||||
_durSub = widget.player.stream.duration.listen((d) {
|
||||
if (mounted) setState(() => _duration = d);
|
||||
});
|
||||
if (widget.showPosition) {
|
||||
widget.seekPreview.addListener(_onSeekPreviewChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AndroidTimeLabel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
unawaited(_posSub?.cancel());
|
||||
unawaited(_durSub?.cancel());
|
||||
_position = widget.player.state.position;
|
||||
_duration = widget.player.state.duration;
|
||||
_posSub = widget.player.stream.position.listen((p) {
|
||||
if (mounted) setState(() => _position = p);
|
||||
});
|
||||
_durSub = widget.player.stream.duration.listen((d) {
|
||||
if (mounted) setState(() => _duration = d);
|
||||
});
|
||||
}
|
||||
if (oldWidget.seekPreview != widget.seekPreview) {
|
||||
oldWidget.seekPreview.removeListener(_onSeekPreviewChanged);
|
||||
if (widget.showPosition) {
|
||||
widget.seekPreview.addListener(_onSeekPreviewChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSeekPreviewChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_posSub?.cancel());
|
||||
unawaited(_durSub?.cancel());
|
||||
widget.seekPreview.removeListener(_onSeekPreviewChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Duration display;
|
||||
if (widget.showPosition) {
|
||||
display = widget.seekPreview.value ?? _position;
|
||||
} else {
|
||||
display = _duration;
|
||||
}
|
||||
return Text(
|
||||
formatDurationClock(display),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageBottomBar on _PlayerPageState {
|
||||
Widget _buildBottomBar(PlaybackSession session) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!_pip.isPip) _buildLogoRow(session),
|
||||
DebouncedSeekBar(
|
||||
player: session.engine,
|
||||
onDragChanged: (t) => _seekPreview.value = t,
|
||||
),
|
||||
SizedBox(
|
||||
height: _pip.isPip ? 32 : 52,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _barVersion,
|
||||
builder: (context, _, _) {
|
||||
if (_pip.isPip) return _buildPipBottomRow(session);
|
||||
return _buildFullBottomRow(session);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogoRow(PlaybackSession session) {
|
||||
final logoUrl = session.context.logoUrl;
|
||||
if (logoUrl == null) return const SizedBox.shrink();
|
||||
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200, maxHeight: 48),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
httpHeaders: imageHeaders,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomLeft,
|
||||
memCacheWidth: 600,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
placeholder: (_, _) => const SizedBox.shrink(),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
List<Widget> _transportControls(
|
||||
PlaybackSession session, {
|
||||
double? iconSize,
|
||||
bool compact = false,
|
||||
}) {
|
||||
final pl = _playlist;
|
||||
final hasPrev = pl?.hasPrev ?? false;
|
||||
final hasNext = pl?.hasNext ?? false;
|
||||
final btnConstraints = compact
|
||||
? const BoxConstraints(minWidth: 36, minHeight: 36)
|
||||
: null;
|
||||
final btnPadding = compact ? EdgeInsets.zero : null;
|
||||
return [
|
||||
IconButton(
|
||||
onPressed: hasPrev ? () => unawaited(pl!.prev()) : null,
|
||||
icon: Icon(
|
||||
Icons.skip_previous,
|
||||
color: hasPrev ? Colors.white : Colors.white38,
|
||||
size: iconSize,
|
||||
),
|
||||
tooltip: '上一集',
|
||||
constraints: btnConstraints,
|
||||
padding: btnPadding,
|
||||
),
|
||||
PlayerPlayPauseButton(
|
||||
player: session.engine,
|
||||
iconSize: compact ? 24.0 : null,
|
||||
constraints: btnConstraints,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: hasNext ? () => unawaited(pl!.next()) : null,
|
||||
icon: Icon(
|
||||
Icons.skip_next,
|
||||
color: hasNext ? Colors.white : Colors.white38,
|
||||
size: iconSize,
|
||||
),
|
||||
tooltip: '下一集',
|
||||
constraints: btnConstraints,
|
||||
padding: btnPadding,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildPipBottomRow(PlaybackSession session) {
|
||||
return Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
PlayerPositionIndicator(
|
||||
player: session.engine,
|
||||
seekPreview: _seekPreview,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: () => unawaited(_togglePip()),
|
||||
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 20),
|
||||
tooltip: '退出画中画 (P)',
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPipCenterControls(PlaybackSession session) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _transportControls(session, iconSize: 26, compact: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFullBottomRow(PlaybackSession session) {
|
||||
final allMediaSources = session.context.allMediaSources;
|
||||
final embySubtitles = session.context.embySubtitles;
|
||||
final hasPicker = session.context.seriesId != null;
|
||||
|
||||
final compactRow = Row(
|
||||
children: [
|
||||
..._transportControls(session, iconSize: 24, compact: true),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: PlayerPositionIndicator(
|
||||
player: session.engine,
|
||||
seekPreview: _seekPreview,
|
||||
),
|
||||
),
|
||||
),
|
||||
_compactMoreButton(session),
|
||||
IconButton(
|
||||
tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏',
|
||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () => unawaited(_toggleFullscreen()),
|
||||
icon: Icon(
|
||||
_fullscreen.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final row = Row(
|
||||
children: [
|
||||
..._transportControls(session),
|
||||
if (!Platform.isAndroid)
|
||||
PlayerVolumeButton(
|
||||
player: session.engine,
|
||||
volumeBoost: ref.watch(
|
||||
audioSettingsProvider.select(
|
||||
(s) => s.value?.volumeBoost ?? false,
|
||||
),
|
||||
),
|
||||
),
|
||||
PlayerPositionIndicator(
|
||||
player: session.engine,
|
||||
seekPreview: _seekPreview,
|
||||
),
|
||||
const Spacer(),
|
||||
if (allMediaSources.length > 1 || _crossServerCards.isNotEmpty)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _versionBarOpen,
|
||||
builder: (context, open, _) => PanelToggleAffordance(
|
||||
isOpen: open,
|
||||
onTap: _toggleVersionBar,
|
||||
tooltip: '切换版本',
|
||||
child: const Icon(
|
||||
Icons.switch_video,
|
||||
color: Colors.white,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
CompositedTransformTarget(
|
||||
link: _panelLinks[PlayerPanelCategory.subtitle]!,
|
||||
child: SubtitleTracksButton(
|
||||
player: session.engine,
|
||||
embySubtitles: embySubtitles,
|
||||
isPanelOpen: _drawer.category == PlayerPanelCategory.subtitle,
|
||||
onTogglePanel: () => _togglePanel(PlayerPanelCategory.subtitle),
|
||||
),
|
||||
),
|
||||
CompositedTransformTarget(
|
||||
link: _panelLinks[PlayerPanelCategory.audio]!,
|
||||
child: AudioTracksButton(
|
||||
player: session.engine,
|
||||
isPanelOpen: _drawer.category == PlayerPanelCategory.audio,
|
||||
onTogglePanel: () => _togglePanel(PlayerPanelCategory.audio),
|
||||
),
|
||||
),
|
||||
CompositedTransformTarget(
|
||||
link: _panelLinks[PlayerPanelCategory.speed]!,
|
||||
child: SpeedButton(
|
||||
currentRate: _playbackRate,
|
||||
isPanelOpen: _drawer.category == PlayerPanelCategory.speed,
|
||||
onTogglePanel: () => _togglePanel(PlayerPanelCategory.speed),
|
||||
),
|
||||
),
|
||||
CompositedTransformTarget(
|
||||
link: _panelLinks[PlayerPanelCategory.fit]!,
|
||||
child: FitButton(
|
||||
currentFit: _videoFit.value,
|
||||
isPanelOpen: _drawer.category == PlayerPanelCategory.fit,
|
||||
onTogglePanel: () => _togglePanel(PlayerPanelCategory.fit),
|
||||
),
|
||||
),
|
||||
if (hasPicker) _panelIcon(PlayerPanelCategory.episode),
|
||||
_panelIcon(PlayerPanelCategory.danmaku),
|
||||
if (_isDesktopWindow)
|
||||
IconButton(
|
||||
onPressed: () => unawaited(_togglePip()),
|
||||
icon: const Icon(
|
||||
Icons.picture_in_picture,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
tooltip: '画中画 (P)',
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏',
|
||||
onPressed: () => unawaited(_toggleFullscreen()),
|
||||
icon: Icon(
|
||||
_fullscreen.isFullscreen
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < Breakpoints.compact) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: compactRow,
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: IntrinsicWidth(child: row),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _compactMoreButton(PlaybackSession session) {
|
||||
final hasVersion =
|
||||
session.context.allMediaSources.length > 1 ||
|
||||
_crossServerCards.isNotEmpty;
|
||||
final hasPicker = session.context.seriesId != null;
|
||||
final items = <_CompactMoreItem>[
|
||||
if (hasVersion)
|
||||
_CompactMoreItem(
|
||||
icon: Icons.switch_video,
|
||||
label: '切换版本',
|
||||
onTap: _toggleVersionBar,
|
||||
),
|
||||
_CompactMoreItem.panel(
|
||||
PlayerPanelCategory.subtitle,
|
||||
(c) => _showCompactPanelSheet(session, c),
|
||||
),
|
||||
_CompactMoreItem.panel(
|
||||
PlayerPanelCategory.audio,
|
||||
(c) => _showCompactPanelSheet(session, c),
|
||||
),
|
||||
_CompactMoreItem.panel(
|
||||
PlayerPanelCategory.speed,
|
||||
(c) => _showCompactPanelSheet(session, c),
|
||||
),
|
||||
_CompactMoreItem.panel(
|
||||
PlayerPanelCategory.fit,
|
||||
(c) => _showCompactPanelSheet(session, c),
|
||||
),
|
||||
if (hasPicker)
|
||||
_CompactMoreItem.panel(
|
||||
PlayerPanelCategory.episode,
|
||||
(c) => _showCompactPanelSheet(session, c),
|
||||
),
|
||||
_CompactMoreItem.panel(
|
||||
PlayerPanelCategory.danmaku,
|
||||
(c) => _showCompactPanelSheet(session, c),
|
||||
),
|
||||
];
|
||||
|
||||
return IconButton(
|
||||
tooltip: '更多',
|
||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Icons.more_horiz, color: Colors.white, size: 26),
|
||||
onPressed: () => _showCompactMoreSheet(items),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCompactPanelSheet(
|
||||
PlaybackSession session,
|
||||
PlayerPanelCategory category,
|
||||
) async {
|
||||
_versionBarOpen.value = false;
|
||||
_drawer.close();
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) {
|
||||
if (category == PlayerPanelCategory.episode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _episodeBodyKey.currentState?.ensureLoaded(),
|
||||
);
|
||||
}
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
|
||||
),
|
||||
child: PlayerPanelShell(
|
||||
icon: panelCategoryIcon(category),
|
||||
title: panelCategoryLabel(category),
|
||||
width: double.infinity,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
child: _panelBody(session, category),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCompactMoreSheet(List<_CompactMoreItem> items) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: kPanelBg,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'更多控制',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
panelDivider(),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.56,
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
panelDivider(),
|
||||
itemBuilder: (context, i) {
|
||||
final item = items[i];
|
||||
return ListTile(
|
||||
leading: Icon(item.icon, color: Colors.white70),
|
||||
title: Text(
|
||||
item.label,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white38,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
item.onTap();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactMoreItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CompactMoreItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
factory _CompactMoreItem.panel(
|
||||
PlayerPanelCategory category,
|
||||
ValueChanged<PlayerPanelCategory> onToggle,
|
||||
) {
|
||||
return _CompactMoreItem(
|
||||
icon: panelCategoryIcon(category),
|
||||
label: panelCategoryLabel(category),
|
||||
onTap: () => onToggle(category),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
|
||||
bool _isDanmakuLayoutChange(DanmakuPanelSettings a, DanmakuPanelSettings b) {
|
||||
return a.maxRows != b.maxRows ||
|
||||
a.fontSize != b.fontSize ||
|
||||
a.lineHeight != b.lineHeight ||
|
||||
a.fontFamily != b.fontFamily ||
|
||||
a.area != b.area;
|
||||
}
|
||||
|
||||
void _syncDanmakuFeederForSession(
|
||||
DanmakuFeeder feeder,
|
||||
PlaybackSession session,
|
||||
double posSec, {
|
||||
bool clear = false,
|
||||
}) {
|
||||
feeder.seekTo(posSec, clear: clear);
|
||||
if (session.isDanmakuActive) {
|
||||
feeder.playFrom(posSec);
|
||||
} else {
|
||||
feeder.freezeAt(posSec);
|
||||
}
|
||||
}
|
||||
|
||||
extension _PlayerPageDanmaku on _PlayerPageState {
|
||||
DanmakuOption _buildDanmakuOption(DanmakuPanelSettings settings) {
|
||||
final base = settings.toDanmakuOption(screenHeight: _danmakuOverlayHeight);
|
||||
if (_playbackRate == 1.0 || _playbackRate <= 0) return base;
|
||||
return base.copyWith(duration: base.duration / _playbackRate);
|
||||
}
|
||||
|
||||
void _applyDanmakuPanelSettings(DanmakuPanelSettings settings) {
|
||||
_panelSettings = settings;
|
||||
_feeder.setPanelSettings(settings, option: _buildDanmakuOption(settings));
|
||||
}
|
||||
|
||||
|
||||
void _wireDanmakuListeners() {
|
||||
final session = ref.read(danmakuSessionProvider).value;
|
||||
if (session != null) {
|
||||
_sessionState = session;
|
||||
if (session is DanmakuSessionMatched &&
|
||||
session.commentStatus == DanmakuCommentStatus.ready &&
|
||||
!identical(session, _lastSyncedDanmakuSession)) {
|
||||
_lastSyncedDanmakuSession = session;
|
||||
_feeder.setComments(session.comments);
|
||||
final s = _session;
|
||||
if (s != null) {
|
||||
_syncDanmakuFeederForSession(
|
||||
_feeder,
|
||||
s,
|
||||
s.engine.state.position.inMilliseconds / 1000.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
final settingsPanel = ref
|
||||
.read(danmakuSettingsProvider)
|
||||
.value
|
||||
?.panelSettings;
|
||||
if (settingsPanel != null && settingsPanel != _panelSettings) {
|
||||
_applyDanmakuPanelSettings(settingsPanel);
|
||||
}
|
||||
|
||||
ref.listen<AsyncValue<DanmakuSessionState>>(danmakuSessionProvider, (
|
||||
prev,
|
||||
next,
|
||||
) {
|
||||
final state = next.value;
|
||||
if (state == null) return;
|
||||
_sessionState = state;
|
||||
|
||||
if (state is DanmakuSessionMatched &&
|
||||
state.commentStatus == DanmakuCommentStatus.ready) {
|
||||
_lastSyncedDanmakuSession = state;
|
||||
_feeder.setComments(state.comments);
|
||||
final s = _session;
|
||||
if (s != null) {
|
||||
final posSec = s.engine.state.position.inMilliseconds / 1000.0;
|
||||
_syncDanmakuFeederForSession(_feeder, s, posSec);
|
||||
}
|
||||
} else {
|
||||
_lastSyncedDanmakuSession = null;
|
||||
_feeder.setComments(const []);
|
||||
}
|
||||
|
||||
_invalidateBottomBar();
|
||||
_refreshPanel();
|
||||
_invalidateDanmakuOverlay();
|
||||
});
|
||||
|
||||
ref.listen<AsyncValue<DanmakuSettingsState>>(danmakuSettingsProvider, (
|
||||
prev,
|
||||
next,
|
||||
) {
|
||||
final settings = next.value;
|
||||
if (settings == null) return;
|
||||
final newPanel = settings.panelSettings;
|
||||
final oldPanel = _panelSettings;
|
||||
final panelChanged = oldPanel != newPanel;
|
||||
final previousSettings = prev?.value;
|
||||
final keywordsChanged =
|
||||
previousSettings != null &&
|
||||
!listEquals(
|
||||
previousSettings.blockedKeywords,
|
||||
settings.blockedKeywords,
|
||||
);
|
||||
|
||||
final enabledChanged = oldPanel.enabled != newPanel.enabled;
|
||||
final offsetChanged = oldPanel.offset != newPanel.offset;
|
||||
final layoutChanged = _isDanmakuLayoutChange(oldPanel, newPanel);
|
||||
|
||||
|
||||
final opacityChanged = oldPanel.opacity != newPanel.opacity;
|
||||
if (panelChanged) {
|
||||
_applyDanmakuPanelSettings(newPanel);
|
||||
if (enabledChanged) {
|
||||
_invalidateBottomBar();
|
||||
}
|
||||
_invalidateDanmakuOverlay();
|
||||
final s = _session;
|
||||
if ((enabledChanged ||
|
||||
offsetChanged ||
|
||||
layoutChanged ||
|
||||
opacityChanged) &&
|
||||
s != null) {
|
||||
final posSec = s.engine.state.position.inMilliseconds / 1000.0;
|
||||
_syncDanmakuFeederForSession(_feeder, s, posSec, clear: true);
|
||||
}
|
||||
}
|
||||
if (panelChanged || keywordsChanged) {
|
||||
_refreshPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool get _danmakuLoaded =>
|
||||
_sessionState is DanmakuSessionMatched ||
|
||||
_sessionState is DanmakuSessionEmpty;
|
||||
|
||||
bool get _danmakuSearching =>
|
||||
_sessionState is DanmakuSessionMatching ||
|
||||
(_sessionState is DanmakuSessionMatched &&
|
||||
(_sessionState as DanmakuSessionMatched).commentStatus ==
|
||||
DanmakuCommentStatus.loading);
|
||||
|
||||
List<DanmakuMatchCandidate> get _danmakuMatches {
|
||||
final s = _sessionState;
|
||||
return s is DanmakuSessionMatched ? s.matches : const [];
|
||||
}
|
||||
|
||||
int get _danmakuSelectedMatchIndex {
|
||||
final s = _sessionState;
|
||||
return s is DanmakuSessionMatched ? s.selectedIndex : -1;
|
||||
}
|
||||
|
||||
int get _danmakuCommentCount {
|
||||
final s = _sessionState;
|
||||
return s is DanmakuSessionMatched ? s.comments.length : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageLifecycle on _PlayerPageState {
|
||||
void _onPanelChanged() {
|
||||
if (_drawer.isOpen && _drawer.category == PlayerPanelCategory.episode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _episodeBodyKey.currentState?.ensureLoaded(),
|
||||
);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
try {
|
||||
final directStream = widget.directStream;
|
||||
final activeSession = ref.read(activeSessionProvider);
|
||||
if (activeSession == null && directStream == null) {
|
||||
if (mounted) {
|
||||
setState(() => _errorMessage = '没有活跃的会话');
|
||||
}
|
||||
return;
|
||||
}
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
directStream != null
|
||||
? 'init direct stream'
|
||||
: 'init server=${activeSession!.serverUrl} '
|
||||
'userId=${activeSession.userId}',
|
||||
);
|
||||
|
||||
_prewarmPlayerEngine();
|
||||
|
||||
final bootstrap = await (
|
||||
ref.read(deviceIdProvider.future),
|
||||
ref.read(appVersionProvider.future),
|
||||
ref.read(playbackResolverProvider.future),
|
||||
ref.read(embyGatewayProvider.future),
|
||||
).wait;
|
||||
final deviceId = bootstrap.$1;
|
||||
final appVersion = bootstrap.$2;
|
||||
final resolver = bootstrap.$3;
|
||||
final gateway = bootstrap.$4;
|
||||
if (!mounted) return;
|
||||
|
||||
final traktDepsFuture = directStream == null
|
||||
? _loadTraktDeps()
|
||||
: Future.value(null);
|
||||
final prefetchFuture = directStream == null
|
||||
? _loadPrefetchedPlaybackInfo(activeSession!)
|
||||
: Future.value(null);
|
||||
|
||||
final scrobbleStatus = ref.read(traktScrobbleStatusProvider.notifier);
|
||||
|
||||
final pendingSeq = ref.read(pendingCrossServerSequenceProvider);
|
||||
if (pendingSeq != null) {
|
||||
ref.read(pendingCrossServerSequenceProvider.notifier).state = null;
|
||||
}
|
||||
|
||||
final settingsFuture = (
|
||||
ref
|
||||
.read(subtitleSettingsProvider.future)
|
||||
.then(
|
||||
(value) => value,
|
||||
onError: (Object e) {
|
||||
AppLogger.warn(_tag, 'load subtitle settings failed', e);
|
||||
return const SubtitleStyleSettings();
|
||||
},
|
||||
),
|
||||
Future.value(
|
||||
ref.read(playerSettingsProvider).value ?? const PlayerSettingsState(),
|
||||
),
|
||||
ref
|
||||
.read(danmakuSettingsProvider.future)
|
||||
.then(
|
||||
(value) => value,
|
||||
onError: (Object e) {
|
||||
AppLogger.warn(_tag, 'load danmaku settings failed', e);
|
||||
return const DanmakuSettingsState();
|
||||
},
|
||||
),
|
||||
ref
|
||||
.read(audioSettingsProvider.future)
|
||||
.then(
|
||||
(value) => value,
|
||||
onError: (Object e) {
|
||||
AppLogger.warn(_tag, 'load audio settings failed', e);
|
||||
return const AudioSettingsState();
|
||||
},
|
||||
),
|
||||
).wait;
|
||||
final Future<Playlist> playlistFuture;
|
||||
if (directStream != null) {
|
||||
final itemType = directStream.episode != null ? 'Episode' : 'Movie';
|
||||
final syntheticItem = EmbyRawItem(
|
||||
Id: widget.itemId,
|
||||
Name: directStream.title,
|
||||
Type: itemType,
|
||||
SeriesName: directStream.seriesName,
|
||||
IndexNumber: directStream.episode,
|
||||
RunTimeTicks: directStream.durationSeconds == null
|
||||
? null
|
||||
: directStream.durationSeconds! * kTicksPerSecond,
|
||||
);
|
||||
playlistFuture = Future<Playlist>.value(
|
||||
SingleItemPlaylist(
|
||||
item: PlaylistItem(
|
||||
itemId: widget.itemId,
|
||||
displayLabel: directStream.title,
|
||||
playFromStart: true,
|
||||
preloadedItem: syntheticItem,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
playlistFuture = playlistFromLaunch(
|
||||
itemId: widget.itemId,
|
||||
mediaSourceId: widget.mediaSourceId,
|
||||
startPositionTicks: widget.startPositionTicks,
|
||||
preferredAudioStreamIndex: widget.preferredAudioStreamIndex,
|
||||
preferredSubtitleStreamIndex: widget.preferredSubtitleStreamIndex,
|
||||
playFromStart: widget.fromStart,
|
||||
crossServerSequence: pendingSeq,
|
||||
loadDetail: (id) {
|
||||
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
|
||||
return ref.read(
|
||||
mediaDetailDataProvider((serverId: serverId, itemId: id)).future,
|
||||
);
|
||||
},
|
||||
loadEpisodes: ({required seriesId, required seasonId}) {
|
||||
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
|
||||
return ref.read(
|
||||
seriesEpisodesDataProvider((
|
||||
serverId: serverId,
|
||||
seriesId: seriesId,
|
||||
seasonId: seasonId,
|
||||
)).future,
|
||||
);
|
||||
},
|
||||
loadSeasons: (seriesId) {
|
||||
final serverId = ref.read(activeSessionProvider)?.serverId ?? '';
|
||||
return ref.read(
|
||||
seriesSeasonsDataProvider((
|
||||
serverId: serverId,
|
||||
seriesId: seriesId,
|
||||
)).future,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
final ready = await (settingsFuture, playlistFuture).wait;
|
||||
final settings = ready.$1;
|
||||
_subtitleSettings = settings.$1;
|
||||
final playerSettings =
|
||||
ref.read(playerSettingsProvider).value ?? settings.$2;
|
||||
final danmakuSettings = settings.$3;
|
||||
if (!mounted) return;
|
||||
|
||||
final audioSettings = settings.$4;
|
||||
_prefetchedPlaybackInfo = await prefetchFuture;
|
||||
final traktDeps = await traktDepsFuture;
|
||||
if (!mounted) return;
|
||||
|
||||
_builder = PlaybackSessionBuilder(
|
||||
resolver: resolver,
|
||||
gateway: gateway,
|
||||
metadataResolver: MediaSourceMetadataResolver(
|
||||
probeService: MediaProbeService.instance,
|
||||
),
|
||||
engineHolder: _engineHolder,
|
||||
readActiveSession: () {
|
||||
|
||||
|
||||
if (!mounted) throw const CancelledException('player unmounted');
|
||||
return ref.read(activeSessionProvider)!;
|
||||
},
|
||||
deviceId: deviceId,
|
||||
deviceName: kEmbyDeviceName,
|
||||
playerSettingsNotifier: _playerSettingsNotifier,
|
||||
audioSettingsNotifier: _audioSettingsNotifier,
|
||||
subtitleSettingsNotifier: ref.read(subtitleSettingsProvider.notifier),
|
||||
loadItemDetail: (serverId, itemId) async {
|
||||
if (!mounted) throw const CancelledException('player unmounted');
|
||||
final res = await ref.read(
|
||||
mediaDetailDataProvider((
|
||||
serverId: serverId,
|
||||
itemId: itemId,
|
||||
)).future,
|
||||
);
|
||||
return res.base;
|
||||
},
|
||||
readEngineOverride: () {
|
||||
if (!mounted) throw const CancelledException('player unmounted');
|
||||
return ref.read(playerEngineOverrideProvider).value ??
|
||||
PlayerEngineOverride.auto;
|
||||
},
|
||||
traktGateway: traktDeps?.gateway,
|
||||
traktQueue: traktDeps?.queue,
|
||||
traktAppVersion: appVersion,
|
||||
traktOnSuccess: scrobbleStatus.recordSuccess,
|
||||
traktOnError: scrobbleStatus.recordError,
|
||||
);
|
||||
|
||||
_initialPlayerSettings = playerSettings;
|
||||
_initialDanmakuSettings = danmakuSettings;
|
||||
_initialAudioSettings = audioSettings;
|
||||
_panelSettings = danmakuSettings.panelSettings;
|
||||
|
||||
_playlist = ready.$2;
|
||||
if (!mounted) {
|
||||
_playlist?.dispose();
|
||||
_playlist = null;
|
||||
return;
|
||||
}
|
||||
_playlist!.current.addListener(_onCurrentItemChanged);
|
||||
_onCurrentItemChanged();
|
||||
} catch (e, s) {
|
||||
AppLogger.error(_tag, 'initialize failed', e, s);
|
||||
if (mounted) setState(() => _errorMessage = formatUserError(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _prewarmPlayerEngine() {
|
||||
if (_engineHolder.current != null) return;
|
||||
final override =
|
||||
ref.read(playerEngineOverrideProvider).value ??
|
||||
PlayerEngineOverride.auto;
|
||||
final kind = resolvePlayerEngineKind(
|
||||
override,
|
||||
isDolbyVision: false,
|
||||
videoCodec: null,
|
||||
);
|
||||
unawaited(() async {
|
||||
try {
|
||||
final engine = await _engineHolder.acquire(
|
||||
kind,
|
||||
() => PlaybackSessionBuilder.createEngine(kind),
|
||||
);
|
||||
AppLogger.debug(_tag, 'engine prewarmed kind=${kind.name}');
|
||||
if (engine is Media3PlayerEngine) {
|
||||
await engine.prewarmNative();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.debug(_tag, 'engine prewarm failed', e);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
Future<({AuthedTraktGateway gateway, TraktPendingSyncQueue queue})?>
|
||||
_loadTraktDeps() async {
|
||||
try {
|
||||
final traktGateway = ref.read(authedTraktGatewayProvider);
|
||||
final queue = await ref.read(traktSyncQueueProvider.future);
|
||||
unawaited(queue.drain());
|
||||
return (gateway: traktGateway, queue: queue);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'trakt deps unavailable, scrobble disabled', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<({int startTimeTicks, Map<String, dynamic> info})?>
|
||||
_loadPrefetchedPlaybackInfo(AuthedSession activeSession) async {
|
||||
try {
|
||||
final startTimeTicks = widget.fromStart
|
||||
? 0
|
||||
: (widget.startPositionTicks ?? 0);
|
||||
final serverId = widget.serverId ?? activeSession.serverId;
|
||||
final key = (
|
||||
serverId: serverId,
|
||||
itemId: widget.itemId,
|
||||
mediaSourceId: widget.mediaSourceId,
|
||||
startTimeTicks: startTimeTicks,
|
||||
);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'playbackInfo prefetch await ${describePlaybackInfoPrefetchKey(key)}',
|
||||
);
|
||||
final prefetched = await ref.read(
|
||||
playbackInfoPrefetchProvider(key).future,
|
||||
);
|
||||
if (prefetched == null || prefetched.info.isEmpty) {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'playbackInfo prefetch miss ${describePlaybackInfoPrefetchKey(key)}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'playbackInfo prefetch hit ${describePlaybackInfoPrefetchKey(key)}',
|
||||
);
|
||||
return (startTimeTicks: prefetched.startTimeTicks, info: prefetched.info);
|
||||
} catch (e) {
|
||||
AppLogger.debug(_tag, 'playbackInfo prefetch failed; fallback', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int _prefetchStartTimeTicksFor(PlaybackRequest req) {
|
||||
if (req.playFromStart) return 0;
|
||||
final explicitTicks = req.startPositionTicks;
|
||||
if (explicitTicks != null && explicitTicks > 0) return explicitTicks;
|
||||
return req.preloadedItem?.UserData?.PlaybackPositionTicks ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageOverlays on _PlayerPageState {
|
||||
List<Widget> _buildVideoOverlays(PlaybackSession session) {
|
||||
return [
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _danmakuOverlayVersion,
|
||||
builder: (context, value, child) {
|
||||
if (!_danmakuLoaded || !_panelSettings.enabled) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return IgnorePointer(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _bufferingOverlayVisible,
|
||||
builder: (context, buffering, child) {
|
||||
return Opacity(opacity: buffering ? 0.0 : 1.0, child: child);
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenHeight = constraints.maxHeight.isFinite
|
||||
? constraints.maxHeight
|
||||
: null;
|
||||
_danmakuOverlayHeight = screenHeight;
|
||||
final option = _buildDanmakuOption(_panelSettings);
|
||||
if (screenHeight != null &&
|
||||
(screenHeight != _danmakuOverlaySyncedHeight ||
|
||||
!identical(
|
||||
_panelSettings,
|
||||
_danmakuOverlaySyncedSettings,
|
||||
))) {
|
||||
_danmakuOverlaySyncedHeight = screenHeight;
|
||||
_danmakuOverlaySyncedSettings = _panelSettings;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_feeder.setPanelSettings(
|
||||
_panelSettings,
|
||||
option: _buildDanmakuOption(_panelSettings),
|
||||
);
|
||||
});
|
||||
}
|
||||
return DanmakuScreen(
|
||||
option: option,
|
||||
createdController: (c) {
|
||||
_feeder.attach(c);
|
||||
_feeder.setPanelSettings(
|
||||
_panelSettings,
|
||||
option: option,
|
||||
);
|
||||
final pos = session.engine.state.position;
|
||||
_syncDanmakuFeederForSession(
|
||||
_feeder,
|
||||
session,
|
||||
pos.inMilliseconds / 1000.0,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _subtitleOverlayVersion,
|
||||
builder: (context, value, child) {
|
||||
return StyledSubtitleOverlay(
|
||||
player: session.engine,
|
||||
settings: _subtitleSettings,
|
||||
externalLines: session.externalSubtitleLines,
|
||||
externalActive: session.activeExternalSubtitle,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: BufferingOverlay(
|
||||
player: session.engine,
|
||||
speedMonitor: session.speedMonitor,
|
||||
suppress: _switchInFlight,
|
||||
onBufferingChanged: (value) {
|
||||
if (mounted) _bufferingOverlayVisible.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _debugHudVisible,
|
||||
builder: (context, visible, _) {
|
||||
if (!visible) return const SizedBox.shrink();
|
||||
return PlayerDebugHud(
|
||||
engine: session.engine,
|
||||
frameMonitor: _frameJankMonitor,
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
List<Widget> _buildPipOverlays(PlaybackSession session) {
|
||||
return [
|
||||
Positioned.fill(
|
||||
child: BufferingOverlay(
|
||||
player: session.engine,
|
||||
speedMonitor: session.speedMonitor,
|
||||
onBufferingChanged: (value) {
|
||||
if (mounted) _bufferingOverlayVisible.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageRelink on _PlayerPageState {
|
||||
|
||||
|
||||
bool _handleStreamFailure() {
|
||||
if (!mounted ||
|
||||
_switchInFlight ||
|
||||
_engineFallbackInFlight ||
|
||||
_relink.pending) {
|
||||
return false;
|
||||
}
|
||||
final s = _session;
|
||||
if (s == null ||
|
||||
s.phase.value != PlaybackPhase.playing ||
|
||||
!s.engine.state.playing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final ticks = ticksFromDuration(s.engine.state.position);
|
||||
|
||||
|
||||
if (s.lastFailureTerminal) {
|
||||
AppLogger.warn(_tag, 'stream failure terminal → skip relink, surface error');
|
||||
_rememberFailurePosition(ticks);
|
||||
s.phase.removeListener(_onSessionPhaseChanged);
|
||||
_attachWakelockSession(null);
|
||||
unawaited(s.dispose());
|
||||
setState(() {
|
||||
_session = null;
|
||||
_errorMessage = '当前版本源无法播放(服务器拒绝,HTTP 4xx)。请切换其他版本源重试。';
|
||||
});
|
||||
_invalidateBottomBar();
|
||||
return true;
|
||||
}
|
||||
|
||||
_relink.schedule(
|
||||
onGiveUp: () {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'relink gave up after ${_relink.attempts} attempts',
|
||||
);
|
||||
_rememberFailurePosition(ticks);
|
||||
s.phase.removeListener(_onSessionPhaseChanged);
|
||||
_attachWakelockSession(null);
|
||||
unawaited(s.dispose());
|
||||
setState(() {
|
||||
_session = null;
|
||||
_errorMessage = '播放链接已失效,多次重连失败';
|
||||
});
|
||||
_invalidateBottomBar();
|
||||
},
|
||||
onRelink: (delay) {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'relink scheduled attempt=${_relink.attempts} '
|
||||
'delay=${delay.inSeconds}s posTicks=$ticks',
|
||||
);
|
||||
final base = _lastRequest;
|
||||
if (!mounted || base == null) return;
|
||||
final req = base.copyWith(
|
||||
startPositionTicks: ticks > 0 ? ticks : null,
|
||||
playFromStart: false,
|
||||
);
|
||||
unawaited(_switchTo(req));
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
void _rememberFailurePosition(int ticks) {
|
||||
final base = _lastRequest;
|
||||
if (base == null || ticks <= 0) return;
|
||||
_lastRequest = base.copyWith(
|
||||
startPositionTicks: ticks,
|
||||
playFromStart: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _armRelinkSustain() {
|
||||
_relink.armSustain();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageSession on _PlayerPageState {
|
||||
Future<void> _switchTo(
|
||||
PlaybackRequest req, {
|
||||
PlayerEngineKind? forcedEngineKind,
|
||||
}) async {
|
||||
if (_switchInFlight) {
|
||||
_pendingResync = true;
|
||||
AppLogger.debug(_tag, '_switchTo busy → will resync to current');
|
||||
return;
|
||||
}
|
||||
final builder = _builder;
|
||||
if (builder == null) {
|
||||
AppLogger.warn(_tag, '_switchTo before initialize');
|
||||
return;
|
||||
}
|
||||
_switchInFlight = true;
|
||||
final stickyOverride =
|
||||
ref.read(playerEngineOverrideProvider).value ??
|
||||
PlayerEngineOverride.auto;
|
||||
final effectiveForcedEngineKind =
|
||||
forcedEngineKind ??
|
||||
(stickyOverride == PlayerEngineOverride.auto
|
||||
? _forcedEngineBySource[_fallbackKeyForRequest(req)]
|
||||
: null);
|
||||
PlayerEngineKind? resolvedEngineKind;
|
||||
var resolvedIsDolbyVision = false;
|
||||
_relink.cancelPending();
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'_switchTo itemId=${req.itemId} '
|
||||
'mediaSourceId=${req.mediaSourceId} '
|
||||
'forced=${effectiveForcedEngineKind?.name ?? '<none>'}',
|
||||
);
|
||||
|
||||
final old = _session;
|
||||
if (old != null) {
|
||||
old.phase.removeListener(_onSessionPhaseChanged);
|
||||
}
|
||||
_attachWakelockSession(null);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_session = null;
|
||||
|
||||
|
||||
_videoVisible = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_drawer.close();
|
||||
_versionBarOpen.value = false;
|
||||
_invalidateBottomBar();
|
||||
}
|
||||
|
||||
PlaybackSession? next;
|
||||
try {
|
||||
_lastRequest = req;
|
||||
final priorDisposeFuture = old?.dispose();
|
||||
if (!mounted) return;
|
||||
|
||||
final prefetched = _prefetchedPlaybackInfo;
|
||||
_prefetchedPlaybackInfo = null;
|
||||
final expectedStartTimeTicks = _prefetchStartTimeTicksFor(req);
|
||||
final usablePrefetch =
|
||||
prefetched != null &&
|
||||
prefetched.info.isNotEmpty &&
|
||||
req.itemId == widget.itemId &&
|
||||
prefetched.startTimeTicks == expectedStartTimeTicks
|
||||
? prefetched.info
|
||||
: null;
|
||||
if (prefetched != null && usablePrefetch == null) {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'drop prefetched PlaybackInfo: item=${req.itemId} '
|
||||
'prefetchTicks=${prefetched.startTimeTicks} '
|
||||
'expectedTicks=$expectedStartTimeTicks',
|
||||
);
|
||||
}
|
||||
|
||||
next = await builder.build(
|
||||
request: req,
|
||||
feeder: _feeder,
|
||||
priorDisposeFuture: priorDisposeFuture,
|
||||
preloadedPlaybackInfo: usablePrefetch,
|
||||
forcedEngineKind: effectiveForcedEngineKind,
|
||||
onEngineResolved: (kind, isDolbyVision) {
|
||||
resolvedEngineKind = kind;
|
||||
resolvedIsDolbyVision = isDolbyVision;
|
||||
},
|
||||
);
|
||||
if (!mounted) {
|
||||
await next.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
next.phase.addListener(_onSessionPhaseChanged);
|
||||
next.onStreamFailure = _handleStreamFailure;
|
||||
next.onExternalSubtitleError = _showExternalSubtitleError;
|
||||
setState(() => _session = next);
|
||||
unawaited(
|
||||
next.engine.stream.position
|
||||
.firstWhere((pos) => pos > Duration.zero)
|
||||
.then((_) {
|
||||
if (!mounted || !identical(_session, next)) return;
|
||||
setState(() => _videoVisible = true);
|
||||
})
|
||||
.catchError((_) {}),
|
||||
);
|
||||
_attachWakelockSession(next);
|
||||
_attachAndroidPipPlayingListener(next);
|
||||
|
||||
unawaited(
|
||||
ref
|
||||
.read(danmakuSessionProvider.notifier)
|
||||
.loadFor(next.context.danmakuMatchContext),
|
||||
);
|
||||
|
||||
await next.start();
|
||||
if (!mounted) return;
|
||||
|
||||
final manuallyConfirmedMedia3 =
|
||||
stickyOverride == PlayerEngineOverride.media3 &&
|
||||
playerEngineKindFromDisplayName(next.engine.displayName) ==
|
||||
PlayerEngineKind.media3;
|
||||
if (manuallyConfirmedMedia3) {
|
||||
_forcedEngineBySource.remove(_fallbackKeyForRequest(req));
|
||||
}
|
||||
|
||||
if (req.targetServer == null) {
|
||||
final ctx = next.context;
|
||||
final msid = ctx.mediaSourceId;
|
||||
if (msid != null && msid.isNotEmpty) {
|
||||
unawaited(
|
||||
_lastPlayedVersionStore.save(
|
||||
serverId: ctx.serverId,
|
||||
itemId: ctx.itemId,
|
||||
mediaSourceId: msid,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (_playbackRate != 1.0) {
|
||||
await next.setRate(_playbackRate);
|
||||
}
|
||||
|
||||
_configureAndroidPip();
|
||||
|
||||
_armRelinkSustain();
|
||||
} catch (e, s) {
|
||||
|
||||
|
||||
if (e is CancelledException || !mounted) {
|
||||
AppLogger.debug(_tag, '_switchTo aborted (unmounted/cancelled)');
|
||||
if (next != null) {
|
||||
next.phase.removeListener(_onSessionPhaseChanged);
|
||||
try {
|
||||
await next.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
AppLogger.error(_tag, '_switchTo failed', e, s);
|
||||
final failedKind = next == null
|
||||
? resolvedEngineKind
|
||||
: playerEngineKindFromDisplayName(next.engine.displayName);
|
||||
if (mounted &&
|
||||
await _tryFallbackFromSwitchFailure(
|
||||
request: req,
|
||||
failedKind: failedKind,
|
||||
failedSession: next,
|
||||
isDolbyVision: next?.context.isDolbyVision ?? resolvedIsDolbyVision,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (next != null) {
|
||||
next.phase.removeListener(_onSessionPhaseChanged);
|
||||
try {
|
||||
await next.dispose();
|
||||
} catch (disposeErr) {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'_switchTo failed: dispose next failed',
|
||||
disposeErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_session = null;
|
||||
_errorMessage = formatUserError(e);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
_switchInFlight = false;
|
||||
if (mounted && _session?.phase.value == PlaybackPhase.failed) {
|
||||
_onSessionPhaseChanged();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_invalidateBottomBar();
|
||||
}
|
||||
}
|
||||
|
||||
await _drainPendingResync();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _drainPendingResync() async {
|
||||
if (!_pendingResync || !mounted) return;
|
||||
_pendingResync = false;
|
||||
final current = _playlist?.current.value;
|
||||
if (current == null || current.itemId.isEmpty) return;
|
||||
if (current.itemId == _session?.context.itemId) return;
|
||||
AppLogger.debug(_tag, '_drainPendingResync → realign to ${current.itemId}');
|
||||
_onCurrentItemChanged();
|
||||
}
|
||||
|
||||
void _onSessionPhaseChanged() {
|
||||
final phase = _session?.phase.value;
|
||||
AppLogger.debug(_tag, 'session phase: $phase');
|
||||
_syncWakelockForSession(_session);
|
||||
if (phase == PlaybackPhase.ended && !_switchInFlight) {
|
||||
final pl = _playlist;
|
||||
if (pl != null && pl.hasNext) {
|
||||
unawaited(pl.next());
|
||||
}
|
||||
} else if (phase == PlaybackPhase.failed && mounted) {
|
||||
if (_switchInFlight) return;
|
||||
final failed = _session;
|
||||
failed?.phase.removeListener(_onSessionPhaseChanged);
|
||||
_attachWakelockSession(null);
|
||||
if (_tryFallbackFromFailedSession(failed)) {
|
||||
return;
|
||||
}
|
||||
if (failed != null) {
|
||||
_rememberFailurePosition(ticksFromDuration(failed.engine.state.position));
|
||||
}
|
||||
setState(() {
|
||||
_session = null;
|
||||
_errorMessage = formatUserError(failed?.lastError);
|
||||
});
|
||||
if (failed != null) {
|
||||
unawaited(failed.dispose());
|
||||
}
|
||||
_invalidateBottomBar();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
final req = _lastRequest;
|
||||
if (req == null) return;
|
||||
|
||||
|
||||
_relink.reset();
|
||||
if (mounted) setState(() => _errorMessage = null);
|
||||
await _switchTo(req);
|
||||
}
|
||||
|
||||
Future<bool> _tryFallbackFromSwitchFailure({
|
||||
required PlaybackRequest request,
|
||||
required PlayerEngineKind? failedKind,
|
||||
required bool isDolbyVision,
|
||||
PlaybackSession? failedSession,
|
||||
}) async {
|
||||
if (_engineFallbackInFlight || failedKind == null) return false;
|
||||
final override =
|
||||
ref.read(playerEngineOverrideProvider).value ??
|
||||
PlayerEngineOverride.auto;
|
||||
final key = _fallbackKeyForRequest(request);
|
||||
final fallbackTarget = nextAutoFallbackEngine(
|
||||
override: override,
|
||||
failedEngineKind: failedKind,
|
||||
isDolbyVision: isDolbyVision,
|
||||
);
|
||||
if (fallbackTarget == null) return false;
|
||||
|
||||
_forcedEngineBySource[key] = fallbackTarget;
|
||||
_engineFallbackInFlight = true;
|
||||
_relink.cancelPending();
|
||||
failedSession?.phase.removeListener(_onSessionPhaseChanged);
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'${failedKind.name} switch failed for ${request.itemId} '
|
||||
'mediaSource=${request.mediaSourceId ?? '<auto>'}; '
|
||||
'fallback to ${fallbackTarget.name}',
|
||||
);
|
||||
if (failedSession != null) {
|
||||
await failedSession.dispose();
|
||||
}
|
||||
if (!mounted) {
|
||||
_engineFallbackInFlight = false;
|
||||
return true;
|
||||
}
|
||||
setState(() {
|
||||
_session = null;
|
||||
_errorMessage = null;
|
||||
});
|
||||
unawaited(
|
||||
Future<void>.microtask(() async {
|
||||
try {
|
||||
await _switchTo(request, forcedEngineKind: fallbackTarget);
|
||||
} finally {
|
||||
_engineFallbackInFlight = false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
_invalidateBottomBar();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _tryFallbackFromFailedSession(PlaybackSession? failed) {
|
||||
if (failed == null || _switchInFlight || _engineFallbackInFlight) {
|
||||
return false;
|
||||
}
|
||||
final base = _lastRequest;
|
||||
if (base == null) return false;
|
||||
final override =
|
||||
ref.read(playerEngineOverrideProvider).value ??
|
||||
PlayerEngineOverride.auto;
|
||||
final failedKind = playerEngineKindFromDisplayName(
|
||||
failed.engine.displayName,
|
||||
);
|
||||
final key = _fallbackKeyForRequest(base);
|
||||
final fallbackTarget = nextAutoFallbackEngine(
|
||||
override: override,
|
||||
failedEngineKind: failedKind,
|
||||
isDolbyVision: failed.context.isDolbyVision,
|
||||
);
|
||||
if (fallbackTarget == null) return false;
|
||||
|
||||
_forcedEngineBySource[key] = fallbackTarget;
|
||||
_engineFallbackInFlight = true;
|
||||
_relink.cancelPending();
|
||||
|
||||
final ticks = ticksFromDuration(failed.engine.state.position);
|
||||
final req = ticks > 0
|
||||
? base.copyWith(startPositionTicks: ticks, playFromStart: false)
|
||||
: base;
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'${failedKind?.name ?? "unknown"} failed for ${failed.context.itemId} '
|
||||
'mediaSource=${failed.context.mediaSourceId ?? '<auto>'}; '
|
||||
'fallback to ${fallbackTarget.name}',
|
||||
);
|
||||
setState(() {
|
||||
_session = null;
|
||||
_errorMessage = null;
|
||||
});
|
||||
unawaited(
|
||||
failed.dispose().whenComplete(() async {
|
||||
try {
|
||||
await _switchTo(req, forcedEngineKind: fallbackTarget);
|
||||
} finally {
|
||||
_engineFallbackInFlight = false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
_invalidateBottomBar();
|
||||
return true;
|
||||
}
|
||||
|
||||
String _fallbackKeyForRequest(PlaybackRequest request) {
|
||||
return [
|
||||
request.targetServer?.serverId ?? '',
|
||||
request.itemId,
|
||||
request.mediaSourceId ?? '',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
|
||||
void _showExternalSubtitleError(String message) {
|
||||
if (!mounted) return;
|
||||
showAppSnackBar(context, message, tone: AppSnackTone.warning);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageSources on _PlayerPageState {
|
||||
PlaybackRequest _itemToRequest(PlaylistItem item) {
|
||||
return PlaybackRequest(
|
||||
itemId: item.itemId,
|
||||
mediaSourceId: item.mediaSourceId,
|
||||
startPositionTicks: item.startPositionTicks,
|
||||
preferredAudioStreamIndex: item.preferredAudioStreamIndex,
|
||||
preferredSubtitleStreamIndex: item.preferredSubtitleStreamIndex,
|
||||
subtitleSettings: _subtitleSettings,
|
||||
playerSettings: _lastRequest?.playerSettings ?? _initialPlayerSettings,
|
||||
danmakuSettings: _lastRequest?.danmakuSettings ?? _initialDanmakuSettings,
|
||||
audioSettings: _lastRequest?.audioSettings ?? _initialAudioSettings,
|
||||
playFromStart: item.playFromStart,
|
||||
targetServer: item.serverIdentity,
|
||||
preloadedItem: item.preloadedItem,
|
||||
directStreamUrl: widget.directStream?.url,
|
||||
directStreamHeaders:
|
||||
widget.directStream?.headers ?? const <String, String>{},
|
||||
);
|
||||
}
|
||||
|
||||
void _onCurrentItemChanged() {
|
||||
final pl = _playlist;
|
||||
if (pl == null) return;
|
||||
final item = pl.current.value;
|
||||
if (item.itemId.isEmpty) return;
|
||||
|
||||
_relink.reset();
|
||||
unawaited(_switchTo(_itemToRequest(item)));
|
||||
}
|
||||
|
||||
Future<void> _onEpisodePicked(EmbyRawItem episode) async {
|
||||
final pl = _playlist;
|
||||
if (pl == null) return;
|
||||
if (episode.Id == _session?.context.itemId) return;
|
||||
if (pl is SeriesEpisodePlaylist && episode.SeasonId != null) {
|
||||
await pl.goToInSeason(seasonId: episode.SeasonId!, episodeId: episode.Id);
|
||||
} else {
|
||||
await pl.goToItemId(episode.Id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _switchVersion(String sourceId) async {
|
||||
if (sourceId == _session?.context.mediaSourceId) return;
|
||||
final base = _lastRequest;
|
||||
if (base == null) return;
|
||||
_relink.reset();
|
||||
final pos = _session?.engine.state.position;
|
||||
final resumeTicks = pos == null ? null : ticksFromDuration(pos);
|
||||
|
||||
|
||||
final req = base.copyWith(
|
||||
mediaSourceId: sourceId,
|
||||
startPositionTicks: (resumeTicks != null && resumeTicks > 0)
|
||||
? resumeTicks
|
||||
: null,
|
||||
playFromStart: resumeTicks != null && resumeTicks <= 0,
|
||||
);
|
||||
await _switchTo(req);
|
||||
}
|
||||
|
||||
|
||||
int? _currentResumeTicks() {
|
||||
final pos = _session?.engine.state.position ?? Duration.zero;
|
||||
final ticks = ticksFromDuration(pos);
|
||||
return ticks > 0 ? ticks : null;
|
||||
}
|
||||
|
||||
|
||||
CrossServerSearchKey? _buildCrossServerKey(PlaybackContext ctx) {
|
||||
final activeServerId = ctx.serverId.isEmpty ? null : ctx.serverId;
|
||||
if (activeServerId == null) return null;
|
||||
final providerIds = ctx.providerIds;
|
||||
final type = ctx.itemType;
|
||||
if (type == 'Movie') {
|
||||
if (providerIds.isEmpty && ctx.itemName.isEmpty) return null;
|
||||
return (
|
||||
activeServerId: activeServerId,
|
||||
anchorType: 'Movie',
|
||||
providerIdQuery: encodeProviderIdsForKey(providerIds),
|
||||
seriesProviderIdQuery: '',
|
||||
parentIndexNumber: -1,
|
||||
indexNumber: -1,
|
||||
itemName: ctx.itemName,
|
||||
excludedServerId: ctx.serverId,
|
||||
);
|
||||
}
|
||||
if (type == 'Episode') {
|
||||
final s = ctx.parentIndexNumber;
|
||||
final e = ctx.itemIndexNumber;
|
||||
if (s == null || e == null) return null;
|
||||
final seriesProviderIds = ctx.seriesProviderIds;
|
||||
if (providerIds.isEmpty && seriesProviderIds.isEmpty) return null;
|
||||
return (
|
||||
activeServerId: activeServerId,
|
||||
anchorType: 'Episode',
|
||||
providerIdQuery: encodeProviderIdsForKey(providerIds),
|
||||
seriesProviderIdQuery: encodeProviderIdsForKey(seriesProviderIds),
|
||||
parentIndexNumber: s,
|
||||
indexNumber: e,
|
||||
itemName: ctx.itemName,
|
||||
excludedServerId: ctx.serverId,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
List<CrossServerSourceCard> _filteredCrossServerCards(
|
||||
List<CrossServerSourceCard> cards,
|
||||
PlaybackContext ctx,
|
||||
) {
|
||||
final curServerId = ctx.serverId;
|
||||
final curSourceId = ctx.mediaSourceId;
|
||||
return cards
|
||||
.where(
|
||||
(c) => !(c.serverId == curServerId && c.mediaSourceId == curSourceId),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
|
||||
Future<void> _switchToCrossServer(CrossServerSourceCard card) async {
|
||||
if (_switchInFlight) return;
|
||||
_relink.reset();
|
||||
final resumeTicks = _currentResumeTicks();
|
||||
final identity = PlaybackServerIdentity.fromCard(card);
|
||||
|
||||
final oldPlaylist = _playlist;
|
||||
oldPlaylist?.current.removeListener(_onCurrentItemChanged);
|
||||
final newPlaylist = CrossServerPlaylist(
|
||||
sequence: [
|
||||
CrossServerPlaylistEntry(
|
||||
serverId: card.serverId,
|
||||
serverName: card.serverName,
|
||||
serverUrl: card.serverUrl,
|
||||
token: card.token,
|
||||
userId: card.userId,
|
||||
itemId: card.landingItemId,
|
||||
label: card.item.Name,
|
||||
preloadedItem: card.item.Id == card.landingItemId ? card.item : null,
|
||||
),
|
||||
],
|
||||
initialIndex: 0,
|
||||
);
|
||||
_playlist = newPlaylist;
|
||||
try {
|
||||
oldPlaylist?.dispose();
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'dispose old playlist failed', e);
|
||||
}
|
||||
|
||||
final base = _lastRequest;
|
||||
final req = PlaybackRequest(
|
||||
itemId: card.landingItemId,
|
||||
mediaSourceId: card.mediaSourceId,
|
||||
startPositionTicks: resumeTicks,
|
||||
subtitleSettings: _subtitleSettings,
|
||||
playerSettings: base?.playerSettings ?? _initialPlayerSettings,
|
||||
danmakuSettings: base?.danmakuSettings ?? _initialDanmakuSettings,
|
||||
audioSettings: base?.audioSettings ?? _initialAudioSettings,
|
||||
playFromStart: resumeTicks == null,
|
||||
targetServer: identity,
|
||||
);
|
||||
newPlaylist.current.addListener(_onCurrentItemChanged);
|
||||
await _switchTo(req);
|
||||
}
|
||||
|
||||
|
||||
void _toggleVersionBar() {
|
||||
if (_versionBarOpen.value) {
|
||||
_versionBarOpen.value = false;
|
||||
} else {
|
||||
_drawer.close();
|
||||
_versionBarOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onAudioTrackSelected(PlaybackSession session, AudioTrack track) {
|
||||
final audioSettings = _lastRequest?.audioSettings ?? _initialAudioSettings;
|
||||
if (!audioSettings.rememberAudioTrack) return;
|
||||
final embyTracks = session.context.embyAudioTracks;
|
||||
if (embyTracks.isEmpty) return;
|
||||
var embyTrack = embyTracks.where((t) => t.index == track.index).firstOrNull;
|
||||
if (embyTrack == null) {
|
||||
final engineTracks = session.engine.state.tracks.embeddedAudio;
|
||||
final pos = engineTracks.indexWhere((t) => t.id == track.id);
|
||||
if (pos >= 0 && pos < embyTracks.length) embyTrack = embyTracks[pos];
|
||||
}
|
||||
if (embyTrack == null) return;
|
||||
unawaited(
|
||||
_audioSettingsNotifier.saveLastAudioTrack(
|
||||
session.context.memoryKey,
|
||||
RememberedAudioTrack(
|
||||
streamIndex: embyTrack.index,
|
||||
language: embyTrack.language,
|
||||
codec: embyTrack.codec,
|
||||
channels: embyTrack.channels,
|
||||
displayTitle: embyTrack.displayTitle,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _onSubtitleSelected(PlaybackSession session, int? streamIndex) {
|
||||
unawaited(session.selectSubtitleByStreamIndex(streamIndex));
|
||||
if (streamIndex == null) {
|
||||
unawaited(
|
||||
_subtitleSettingsNotifier.saveLastSubtitleTrack(
|
||||
session.context.memoryKey,
|
||||
RememberedSubtitleTrack(streamIndex: -1, updatedAt: DateTime.now()),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final embyTrack = session.context.embySubtitles
|
||||
.where((track) => track.index == streamIndex)
|
||||
.firstOrNull;
|
||||
if (embyTrack == null) {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'subtitle memory: streamIdx=$streamIndex not in context.embySubtitles; '
|
||||
'skipping memory write',
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
_subtitleSettingsNotifier.saveLastSubtitleTrack(
|
||||
session.context.memoryKey,
|
||||
RememberedSubtitleTrack(
|
||||
streamIndex: embyTrack.index,
|
||||
language: embyTrack.language,
|
||||
title: embyTrack.title,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageTopBar on _PlayerPageState {
|
||||
String _buildTitleText(PlaybackSession session) {
|
||||
final idx = session.context.itemIndexNumber;
|
||||
final name = session.context.itemName;
|
||||
final series = session.context.seriesName;
|
||||
if (series != null && series.isNotEmpty) {
|
||||
if (idx != null) return '$series ${formatEpisodeNumber(idx)} $name';
|
||||
return '$series $name';
|
||||
}
|
||||
if (idx != null) return '${formatEpisodeNumber(idx)} $name';
|
||||
return name;
|
||||
}
|
||||
|
||||
MediaQualityInfo? _activeQualityInfo(PlaybackSession session) {
|
||||
final srcId = session.context.mediaSourceId;
|
||||
for (final src in session.context.allMediaSources) {
|
||||
if (src.Id == srcId) return MediaQualityInfo.fromMediaSource(src);
|
||||
}
|
||||
if (session.context.allMediaSources.isNotEmpty) {
|
||||
return MediaQualityInfo.fromMediaSource(
|
||||
session.context.allMediaSources.first,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Widget> _buildTopBar(PlaybackSession session) {
|
||||
if (_pip.isPip) {
|
||||
return [
|
||||
IconButton(
|
||||
onPressed: _goBack,
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
tooltip: '退出播放',
|
||||
),
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
IconButton(
|
||||
onPressed: () => unawaited(_pip.toggleAlwaysOnTop()),
|
||||
icon: Icon(
|
||||
_pip.isAlwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
color: _pip.isAlwaysOnTop ? Colors.white : Colors.white54,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _pip.isAlwaysOnTop ? '取消置顶' : '置顶',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => unawaited(windowManager.close()),
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
IconButton(
|
||||
onPressed: _goBack,
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: kMinInteractiveDimension,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_buildTitleText(session),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
QualityBadge(quality: _activeQualityInfo(session)),
|
||||
const SizedBox(width: 8),
|
||||
NetworkSpeedIndicator(monitor: session.speedMonitor),
|
||||
if (!_fullscreen.isFullscreen &&
|
||||
!_pip.isPip &&
|
||||
_isDesktopWindow) ...[
|
||||
const SizedBox(width: 8),
|
||||
const WindowControls.forDarkSurface(),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const PlayerClockText(),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
|
||||
|
||||
part of 'player_page.dart';
|
||||
|
||||
extension _PlayerPageVersionBar on _PlayerPageState {
|
||||
|
||||
|
||||
Widget _buildVersionBarLayer(PlaybackSession session) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _versionBarOpen,
|
||||
builder: (context, open, _) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (open)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _versionBarOpen.value = false,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: Platform.isAndroid ? 96 : 72,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.15),
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: open
|
||||
? _buildVersionBar(session)
|
||||
: const SizedBox.shrink(key: ValueKey('version-bar-empty')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildVersionBar(PlaybackSession session) {
|
||||
final entries = _buildVersionCardData(session);
|
||||
if (entries.isEmpty) {
|
||||
return const SizedBox.shrink(key: ValueKey('version-bar-empty'));
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: KeyedSubtree(
|
||||
key: const ValueKey('version-bar'),
|
||||
child: VersionPanel(
|
||||
entries: entries,
|
||||
onClose: () => _versionBarOpen.value = false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
List<VersionSourceCardData> _buildVersionCardData(PlaybackSession session) {
|
||||
final ctx = session.context;
|
||||
final localSources = ctx.allMediaSources;
|
||||
final currentSourceId = ctx.mediaSourceId;
|
||||
final currentServerName =
|
||||
_lastRequest?.targetServer?.serverName ??
|
||||
ref.read(activeSessionProvider)?.serverName ??
|
||||
'本服务器';
|
||||
|
||||
final entries = <VersionSourceCardData>[];
|
||||
for (final src in localSources) {
|
||||
final isCurrent = src.Id != null && src.Id == currentSourceId;
|
||||
final sourceId = src.Id;
|
||||
entries.add(
|
||||
VersionSourceCardData.fromLocalSource(
|
||||
src,
|
||||
serverName: currentServerName,
|
||||
serverKey: ctx.serverId,
|
||||
isCurrent: isCurrent,
|
||||
tooltip: isCurrent ? null : '以该版本播放',
|
||||
onTap: (isCurrent || sourceId == null)
|
||||
? null
|
||||
: () => _switchVersion(sourceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final card in _crossServerCards) {
|
||||
entries.add(
|
||||
VersionSourceCardData.fromCrossServer(
|
||||
card,
|
||||
onTap: () => _switchToCrossServer(card),
|
||||
),
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../session/playback_target.dart';
|
||||
import 'playlist.dart';
|
||||
|
||||
|
||||
@immutable
|
||||
class CrossServerPlaylistEntry {
|
||||
const CrossServerPlaylistEntry({
|
||||
required this.serverId,
|
||||
required this.serverName,
|
||||
required this.serverUrl,
|
||||
required this.token,
|
||||
required this.userId,
|
||||
required this.itemId,
|
||||
this.label = '',
|
||||
this.preloadedItem,
|
||||
});
|
||||
|
||||
final String serverId;
|
||||
final String serverName;
|
||||
final String serverUrl;
|
||||
final String token;
|
||||
final String userId;
|
||||
final String itemId;
|
||||
final String label;
|
||||
final EmbyRawItem? preloadedItem;
|
||||
|
||||
PlaybackServerIdentity toIdentity() => PlaybackServerIdentity(
|
||||
serverId: serverId,
|
||||
baseUrl: serverUrl,
|
||||
token: token,
|
||||
userId: userId,
|
||||
serverName: serverName,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is CrossServerPlaylistEntry &&
|
||||
other.serverId == serverId &&
|
||||
other.serverName == serverName &&
|
||||
other.serverUrl == serverUrl &&
|
||||
other.token == token &&
|
||||
other.userId == userId &&
|
||||
other.itemId == itemId &&
|
||||
other.label == label &&
|
||||
other.preloadedItem == preloadedItem;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
serverId,
|
||||
serverName,
|
||||
serverUrl,
|
||||
token,
|
||||
userId,
|
||||
itemId,
|
||||
label,
|
||||
preloadedItem,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class CrossServerPlaylist implements Playlist {
|
||||
CrossServerPlaylist({
|
||||
required List<CrossServerPlaylistEntry> sequence,
|
||||
required int initialIndex,
|
||||
bool initialPlayFromStart = false,
|
||||
}) : assert(sequence.isNotEmpty, 'cross-server sequence cannot be empty'),
|
||||
assert(initialIndex >= 0 && initialIndex < sequence.length),
|
||||
_entries = List.unmodifiable(sequence),
|
||||
_currentIndex = ValueNotifier<int>(initialIndex),
|
||||
_current = ValueNotifier<PlaylistItem>(
|
||||
_toItem(sequence[initialIndex], playFromStart: initialPlayFromStart),
|
||||
);
|
||||
|
||||
final List<CrossServerPlaylistEntry> _entries;
|
||||
final ValueNotifier<PlaylistItem> _current;
|
||||
final ValueNotifier<int> _currentIndex;
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
ValueListenable<PlaylistItem> get current => _current;
|
||||
@override
|
||||
ValueListenable<int> get currentIndex => _currentIndex;
|
||||
@override
|
||||
int get length => _entries.length;
|
||||
@override
|
||||
bool get hasNext => _currentIndex.value < _entries.length - 1;
|
||||
@override
|
||||
bool get hasPrev => _currentIndex.value > 0;
|
||||
|
||||
@override
|
||||
Future<void> next() => goTo(_currentIndex.value + 1);
|
||||
@override
|
||||
Future<void> prev() => goTo(_currentIndex.value - 1);
|
||||
|
||||
@override
|
||||
Future<void> goTo(int index) async {
|
||||
if (_disposed) return;
|
||||
if (index < 0 || index >= _entries.length) return;
|
||||
if (index == _currentIndex.value) return;
|
||||
final target = _entries[index];
|
||||
_currentIndex.value = index;
|
||||
_current.value = _toItem(target);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> goToItemId(String itemId) async {
|
||||
final idx = _entries.indexWhere((e) => e.itemId == itemId);
|
||||
if (idx < 0) return;
|
||||
await goTo(idx);
|
||||
}
|
||||
|
||||
static PlaylistItem _toItem(
|
||||
CrossServerPlaylistEntry e, {
|
||||
bool playFromStart = false,
|
||||
}) => PlaylistItem(
|
||||
itemId: e.itemId,
|
||||
serverId: e.serverId,
|
||||
serverIdentity: e.toIdentity(),
|
||||
displayLabel: e.label,
|
||||
playFromStart: playFromStart,
|
||||
preloadedItem: e.preloadedItem,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_current.dispose();
|
||||
_currentIndex.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
|
||||
import 'cross_server_playlist.dart';
|
||||
|
||||
|
||||
final pendingCrossServerSequenceProvider =
|
||||
StateProvider<List<CrossServerPlaylistEntry>?>((ref) => null);
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../session/playback_target.dart';
|
||||
|
||||
|
||||
@immutable
|
||||
class PlaylistItem {
|
||||
const PlaylistItem({
|
||||
required this.itemId,
|
||||
this.mediaSourceId,
|
||||
this.startPositionTicks,
|
||||
this.preferredAudioStreamIndex,
|
||||
this.preferredSubtitleStreamIndex,
|
||||
this.serverId,
|
||||
this.serverIdentity,
|
||||
this.displayLabel = '',
|
||||
this.playFromStart = false,
|
||||
this.preloadedItem,
|
||||
});
|
||||
|
||||
final String itemId;
|
||||
final String? mediaSourceId;
|
||||
final int? startPositionTicks;
|
||||
final int? preferredAudioStreamIndex;
|
||||
final int? preferredSubtitleStreamIndex;
|
||||
|
||||
|
||||
final String? serverId;
|
||||
|
||||
|
||||
final PlaybackServerIdentity? serverIdentity;
|
||||
|
||||
|
||||
final String displayLabel;
|
||||
|
||||
|
||||
final bool playFromStart;
|
||||
|
||||
|
||||
final EmbyRawItem? preloadedItem;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PlaylistItem &&
|
||||
other.itemId == itemId &&
|
||||
other.mediaSourceId == mediaSourceId &&
|
||||
other.startPositionTicks == startPositionTicks &&
|
||||
other.preferredAudioStreamIndex == preferredAudioStreamIndex &&
|
||||
other.preferredSubtitleStreamIndex == preferredSubtitleStreamIndex &&
|
||||
other.serverId == serverId &&
|
||||
other.serverIdentity == serverIdentity &&
|
||||
other.displayLabel == displayLabel &&
|
||||
other.playFromStart == playFromStart &&
|
||||
other.preloadedItem == preloadedItem;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
startPositionTicks,
|
||||
preferredAudioStreamIndex,
|
||||
preferredSubtitleStreamIndex,
|
||||
serverId,
|
||||
serverIdentity,
|
||||
displayLabel,
|
||||
playFromStart,
|
||||
preloadedItem,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PlaylistItem(itemId=$itemId, serverId=$serverId, label=$displayLabel)';
|
||||
}
|
||||
|
||||
|
||||
abstract class Playlist {
|
||||
|
||||
ValueListenable<PlaylistItem> get current;
|
||||
|
||||
|
||||
ValueListenable<int> get currentIndex;
|
||||
|
||||
int get length;
|
||||
bool get hasNext;
|
||||
bool get hasPrev;
|
||||
|
||||
Future<void> next();
|
||||
Future<void> prev();
|
||||
Future<void> goTo(int index);
|
||||
Future<void> goToItemId(String itemId);
|
||||
void dispose();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import '../../../core/contracts/library.dart';
|
||||
import 'cross_server_playlist.dart';
|
||||
import 'playlist.dart';
|
||||
import 'series_episode_playlist.dart';
|
||||
import 'single_item_playlist.dart';
|
||||
|
||||
typedef LoadDetailFn = Future<MediaDetailRes> Function(String itemId);
|
||||
|
||||
|
||||
Future<Playlist> playlistFromLaunch({
|
||||
required String itemId,
|
||||
String? mediaSourceId,
|
||||
int? startPositionTicks,
|
||||
int? preferredAudioStreamIndex,
|
||||
int? preferredSubtitleStreamIndex,
|
||||
bool playFromStart = false,
|
||||
List<CrossServerPlaylistEntry>? crossServerSequence,
|
||||
required LoadDetailFn loadDetail,
|
||||
required LoadEpisodesFn loadEpisodes,
|
||||
required LoadSeasonsFn loadSeasons,
|
||||
}) async {
|
||||
if (crossServerSequence != null && crossServerSequence.isNotEmpty) {
|
||||
var initialIndex = crossServerSequence.indexWhere(
|
||||
(e) => e.itemId == itemId,
|
||||
);
|
||||
if (initialIndex < 0) initialIndex = 0;
|
||||
return CrossServerPlaylist(
|
||||
sequence: crossServerSequence,
|
||||
initialIndex: initialIndex,
|
||||
initialPlayFromStart: playFromStart,
|
||||
);
|
||||
}
|
||||
|
||||
final detail = await loadDetail(itemId);
|
||||
final base = detail.base;
|
||||
if (base.Type == 'Episode' &&
|
||||
base.SeriesId != null &&
|
||||
base.SeasonId != null) {
|
||||
final seriesPlaylist = SeriesEpisodePlaylist(
|
||||
seriesId: base.SeriesId!,
|
||||
loadEpisodes: loadEpisodes,
|
||||
loadSeasons: loadSeasons,
|
||||
);
|
||||
await seriesPlaylist.initialize(
|
||||
seasonId: base.SeasonId!,
|
||||
episodeId: itemId,
|
||||
initialIndexNumber: base.IndexNumber,
|
||||
initialMediaSourceId: mediaSourceId,
|
||||
initialStartPositionTicks: startPositionTicks,
|
||||
initialPreferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
initialPreferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
initialPlayFromStart: playFromStart,
|
||||
preloadedItem: base,
|
||||
);
|
||||
return seriesPlaylist;
|
||||
}
|
||||
|
||||
return SingleItemPlaylist(
|
||||
item: PlaylistItem(
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
startPositionTicks: startPositionTicks,
|
||||
preferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
playFromStart: playFromStart,
|
||||
preloadedItem: base,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../../shared/utils/format_utils.dart';
|
||||
import 'playlist.dart';
|
||||
|
||||
typedef LoadEpisodesFn =
|
||||
Future<List<EmbyRawItem>> Function({
|
||||
required String seriesId,
|
||||
required String seasonId,
|
||||
});
|
||||
|
||||
typedef LoadSeasonsFn = Future<List<EmbyRawSeason>> Function(String seriesId);
|
||||
|
||||
|
||||
class SeriesEpisodePlaylist implements Playlist {
|
||||
SeriesEpisodePlaylist({
|
||||
required this.seriesId,
|
||||
required this.loadEpisodes,
|
||||
required this.loadSeasons,
|
||||
}) : _current = ValueNotifier<PlaylistItem>(_placeholder),
|
||||
_currentIndex = ValueNotifier<int>(-1);
|
||||
|
||||
final String seriesId;
|
||||
final LoadEpisodesFn loadEpisodes;
|
||||
final LoadSeasonsFn loadSeasons;
|
||||
|
||||
final ValueNotifier<PlaylistItem> _current;
|
||||
final ValueNotifier<int> _currentIndex;
|
||||
|
||||
String? _currentSeasonId;
|
||||
List<EmbyRawItem> _episodes = const [];
|
||||
|
||||
|
||||
String? _initialMediaSourceId;
|
||||
int? _initialStartPositionTicks;
|
||||
int? _initialPreferredAudio;
|
||||
int? _initialPreferredSub;
|
||||
bool _initialPlayFromStart = false;
|
||||
bool _initialApplied = false;
|
||||
|
||||
bool _disposed = false;
|
||||
bool _userNavigated = false;
|
||||
|
||||
static const PlaylistItem _placeholder = PlaylistItem(itemId: '');
|
||||
|
||||
|
||||
Future<void> initialize({
|
||||
required String seasonId,
|
||||
required String episodeId,
|
||||
int? initialIndexNumber,
|
||||
String? initialMediaSourceId,
|
||||
int? initialStartPositionTicks,
|
||||
int? initialPreferredAudioStreamIndex,
|
||||
int? initialPreferredSubtitleStreamIndex,
|
||||
bool initialPlayFromStart = false,
|
||||
EmbyRawItem? preloadedItem,
|
||||
}) async {
|
||||
_initialMediaSourceId = initialMediaSourceId;
|
||||
_initialStartPositionTicks = initialStartPositionTicks;
|
||||
_initialPreferredAudio = initialPreferredAudioStreamIndex;
|
||||
_initialPreferredSub = initialPreferredSubtitleStreamIndex;
|
||||
_initialPlayFromStart = initialPlayFromStart;
|
||||
_initialApplied = true;
|
||||
_currentSeasonId = seasonId;
|
||||
_current.value = PlaylistItem(
|
||||
itemId: episodeId,
|
||||
mediaSourceId: initialMediaSourceId,
|
||||
startPositionTicks: initialStartPositionTicks,
|
||||
preferredAudioStreamIndex: initialPreferredAudioStreamIndex,
|
||||
preferredSubtitleStreamIndex: initialPreferredSubtitleStreamIndex,
|
||||
playFromStart: initialPlayFromStart,
|
||||
displayLabel: preloadedItem != null ? _labelOf(preloadedItem) : '',
|
||||
preloadedItem: preloadedItem,
|
||||
);
|
||||
unawaited(_resolveEpisodeList(seasonId, episodeId, initialIndexNumber));
|
||||
}
|
||||
|
||||
|
||||
Future<void> _resolveEpisodeList(
|
||||
String seasonId,
|
||||
String episodeId,
|
||||
int? initialIndexNumber,
|
||||
) async {
|
||||
try {
|
||||
await _loadSeasonEpisodes(seasonId);
|
||||
} catch (e) {
|
||||
AppLogger.warn(
|
||||
'SeriesEpisodePlaylist',
|
||||
'background episode list load failed',
|
||||
e,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_disposed || _userNavigated) return;
|
||||
|
||||
var idx = _episodes.indexWhere((e) => e.Id == episodeId);
|
||||
if (idx < 0 && initialIndexNumber != null) {
|
||||
idx = _episodes.indexWhere((e) => e.IndexNumber == initialIndexNumber);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'SeriesEpisodePlaylist',
|
||||
'episodeListReady seriesId=$seriesId seasonId=$seasonId '
|
||||
'episodeId=$episodeId idx=$idx '
|
||||
'episodes=${_episodes.length} '
|
||||
'episodeIds=${_episodes.map((e) => e.Id).toList()}',
|
||||
);
|
||||
if (_episodes.isEmpty) return;
|
||||
_currentIndex.value = idx < 0 ? 0 : idx;
|
||||
}
|
||||
|
||||
@override
|
||||
ValueListenable<PlaylistItem> get current => _current;
|
||||
@override
|
||||
ValueListenable<int> get currentIndex => _currentIndex;
|
||||
@override
|
||||
int get length => _episodes.length;
|
||||
|
||||
|
||||
@override
|
||||
bool get hasNext => _episodes.isNotEmpty;
|
||||
@override
|
||||
bool get hasPrev => _episodes.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> next() async {
|
||||
if (_disposed || _episodes.isEmpty) return;
|
||||
final cur = _currentIndex.value;
|
||||
if (cur < _episodes.length - 1) {
|
||||
_markMoved();
|
||||
_setCurrent(cur + 1);
|
||||
return;
|
||||
}
|
||||
await _crossSeason(direction: 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> prev() async {
|
||||
if (_disposed || _episodes.isEmpty) return;
|
||||
final cur = _currentIndex.value;
|
||||
if (cur > 0) {
|
||||
_markMoved();
|
||||
_setCurrent(cur - 1);
|
||||
return;
|
||||
}
|
||||
await _crossSeason(direction: -1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> goTo(int index) async {
|
||||
if (_disposed || index < 0 || index >= _episodes.length) return;
|
||||
if (index == _currentIndex.value) return;
|
||||
_markMoved();
|
||||
_setCurrent(index);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> goToItemId(String itemId) async {
|
||||
if (_disposed) return;
|
||||
final idx = _episodes.indexWhere((e) => e.Id == itemId);
|
||||
if (idx < 0) return;
|
||||
if (idx == _currentIndex.value) return;
|
||||
_markMoved();
|
||||
_setCurrent(idx);
|
||||
}
|
||||
|
||||
|
||||
Future<void> goToInSeason({
|
||||
required String seasonId,
|
||||
required String episodeId,
|
||||
}) async {
|
||||
if (_disposed) return;
|
||||
if (seasonId != _currentSeasonId || _episodes.isEmpty) {
|
||||
await _loadSeasonEpisodes(seasonId);
|
||||
if (_disposed) return;
|
||||
}
|
||||
final idx = _episodes.indexWhere((e) => e.Id == episodeId);
|
||||
if (idx < 0) return;
|
||||
if (_currentSeasonId == seasonId && idx == _currentIndex.value) return;
|
||||
_markMoved();
|
||||
_setCurrent(idx);
|
||||
}
|
||||
|
||||
Future<void> _crossSeason({required int direction}) async {
|
||||
final curSeason = _currentSeasonId;
|
||||
if (curSeason == null) return;
|
||||
final seasons = await loadSeasons(seriesId);
|
||||
if (_disposed) return;
|
||||
final ordered = [...seasons]
|
||||
..sort((a, b) => (a.IndexNumber ?? 0).compareTo(b.IndexNumber ?? 0));
|
||||
final curPos = ordered.indexWhere((s) => s.Id == curSeason);
|
||||
if (curPos < 0) return;
|
||||
final targetPos = curPos + direction;
|
||||
if (targetPos < 0 || targetPos >= ordered.length) return;
|
||||
final target = ordered[targetPos];
|
||||
await _loadSeasonEpisodes(target.Id);
|
||||
if (_disposed || _episodes.isEmpty) return;
|
||||
_markMoved();
|
||||
_setCurrent(direction > 0 ? 0 : _episodes.length - 1);
|
||||
}
|
||||
|
||||
Future<void> _loadSeasonEpisodes(String seasonId) async {
|
||||
final eps = await loadEpisodes(seriesId: seriesId, seasonId: seasonId);
|
||||
if (_disposed) return;
|
||||
_currentSeasonId = seasonId;
|
||||
_episodes = List.unmodifiable(eps);
|
||||
}
|
||||
|
||||
void _markMoved() {
|
||||
_initialApplied = true;
|
||||
_userNavigated = true;
|
||||
}
|
||||
|
||||
void _setCurrent(int index) {
|
||||
_currentIndex.value = index;
|
||||
_current.value = _itemFromEpisode(_episodes[index]);
|
||||
}
|
||||
|
||||
PlaylistItem _itemFromEpisode(EmbyRawItem ep) {
|
||||
final useInitial = !_initialApplied;
|
||||
_initialApplied = true;
|
||||
return PlaylistItem(
|
||||
itemId: ep.Id,
|
||||
mediaSourceId: useInitial ? _initialMediaSourceId : null,
|
||||
startPositionTicks: useInitial ? _initialStartPositionTicks : null,
|
||||
preferredAudioStreamIndex: useInitial ? _initialPreferredAudio : null,
|
||||
preferredSubtitleStreamIndex: useInitial ? _initialPreferredSub : null,
|
||||
displayLabel: _labelOf(ep),
|
||||
playFromStart: useInitial && _initialPlayFromStart,
|
||||
preloadedItem: ep,
|
||||
);
|
||||
}
|
||||
|
||||
String _labelOf(EmbyRawItem ep) {
|
||||
final ep0 = ep.IndexNumber;
|
||||
final name = ep.Name;
|
||||
if (ep0 != null && name.isNotEmpty) {
|
||||
return '${formatEpisodeNumber(ep0)} $name';
|
||||
}
|
||||
if (name.isNotEmpty) return name;
|
||||
return ep.Id;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_current.dispose();
|
||||
_currentIndex.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'playlist.dart';
|
||||
|
||||
|
||||
class SingleItemPlaylist implements Playlist {
|
||||
SingleItemPlaylist({required PlaylistItem item})
|
||||
: _current = ValueNotifier<PlaylistItem>(item),
|
||||
_currentIndex = ValueNotifier<int>(0);
|
||||
|
||||
final ValueNotifier<PlaylistItem> _current;
|
||||
final ValueNotifier<int> _currentIndex;
|
||||
|
||||
@override
|
||||
ValueListenable<PlaylistItem> get current => _current;
|
||||
@override
|
||||
ValueListenable<int> get currentIndex => _currentIndex;
|
||||
@override
|
||||
int get length => 1;
|
||||
@override
|
||||
bool get hasNext => false;
|
||||
@override
|
||||
bool get hasPrev => false;
|
||||
|
||||
@override
|
||||
Future<void> next() async {}
|
||||
@override
|
||||
Future<void> prev() async {}
|
||||
@override
|
||||
Future<void> goTo(int index) async {}
|
||||
@override
|
||||
Future<void> goToItemId(String itemId) async {}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_current.dispose();
|
||||
_currentIndex.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/infra/pip/android_pip_controller.dart';
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
|
||||
const _tag = 'AndroidPipManager';
|
||||
|
||||
|
||||
class AndroidPipManager {
|
||||
AndroidPipManager({required this._onStateChanged});
|
||||
|
||||
final VoidCallback _onStateChanged;
|
||||
|
||||
bool _isInPip = false;
|
||||
bool _isDisposed = false;
|
||||
bool _supported = false;
|
||||
StreamSubscription<bool>? _modeSub;
|
||||
StreamSubscription<PipAction>? _actionSub;
|
||||
|
||||
|
||||
void Function(PipAction action)? onAction;
|
||||
|
||||
bool get isInPip => _isInPip;
|
||||
bool get isSupported => _supported;
|
||||
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
final supported = await AndroidPipController.instance.isSupported();
|
||||
if (_isDisposed) return;
|
||||
if (_supported != supported) {
|
||||
_supported = supported;
|
||||
_onStateChanged();
|
||||
}
|
||||
if (!_supported) return;
|
||||
if (_modeSub != null || _actionSub != null) return;
|
||||
|
||||
final isInPip = await AndroidPipController.instance.isInPipMode();
|
||||
if (_isDisposed) return;
|
||||
if (_isInPip != isInPip) {
|
||||
_isInPip = isInPip;
|
||||
_onStateChanged();
|
||||
}
|
||||
|
||||
_modeSub = AndroidPipController.instance.pipModeChanges.listen(
|
||||
(isInPip) {
|
||||
if (_isDisposed) return;
|
||||
if (_isInPip == isInPip) return;
|
||||
_isInPip = isInPip;
|
||||
AppLogger.info(_tag, 'PiP mode changed: $isInPip');
|
||||
_onStateChanged();
|
||||
},
|
||||
onError: (Object e) {
|
||||
AppLogger.warn(_tag, 'PiP mode stream error', e);
|
||||
},
|
||||
);
|
||||
|
||||
_actionSub = AndroidPipController.instance.actionStream.listen(
|
||||
(action) {
|
||||
if (_isDisposed) return;
|
||||
AppLogger.debug(_tag, 'PiP action: $action');
|
||||
onAction?.call(action);
|
||||
},
|
||||
onError: (Object e) {
|
||||
AppLogger.warn(_tag, 'PiP action stream error', e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 (!_supported || _isDisposed) return;
|
||||
await AndroidPipController.instance.configure(
|
||||
aspectX: aspectX,
|
||||
aspectY: aspectY,
|
||||
autoEnterOnLeave: autoEnterOnLeave,
|
||||
isPlaying: isPlaying,
|
||||
hasNext: hasNext,
|
||||
hasPrev: hasPrev,
|
||||
useEpisodeActions: useEpisodeActions,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<bool> enter({int? aspectX, int? aspectY}) async {
|
||||
if (!_supported || _isDisposed || _isInPip) return false;
|
||||
return AndroidPipController.instance.enterPip(
|
||||
aspectX: aspectX,
|
||||
aspectY: aspectY,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> updatePlaybackState({
|
||||
required bool isPlaying,
|
||||
required bool hasNext,
|
||||
required bool hasPrev,
|
||||
required bool useEpisodeActions,
|
||||
}) async {
|
||||
if (!_supported || _isDisposed || !_isInPip) return;
|
||||
await AndroidPipController.instance.updatePlaybackState(
|
||||
isPlaying: isPlaying,
|
||||
hasNext: hasNext,
|
||||
hasPrev: hasPrev,
|
||||
useEpisodeActions: useEpisodeActions,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
final modeSub = _modeSub;
|
||||
_modeSub = null;
|
||||
final actionSub = _actionSub;
|
||||
_actionSub = null;
|
||||
onAction = null;
|
||||
unawaited(() async {
|
||||
await AndroidPipController.instance.configure(
|
||||
autoEnterOnLeave: false,
|
||||
isPlaying: false,
|
||||
);
|
||||
await modeSub?.cancel();
|
||||
await actionSub?.cancel();
|
||||
await AndroidPipController.instance.dispose();
|
||||
}());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user