Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+598
View File
@@ -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),
),
],
),
),
),
),
);
}
}
+14
View File
@@ -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,
);
}
+806
View File
@@ -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,
);
}
}
+904
View File
@@ -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,
};
+546
View File
@@ -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,
});
}
+332
View File
@@ -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 分钟',
);
}
}
+637
View File
@@ -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,
),
],
),
),
),
),
),
);
}
}
+316
View File
@@ -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();
}
}
+486
View File
@@ -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;
}
+816
View File
@@ -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,
};
}
+179
View File
@@ -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