Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,199 @@
import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:forui/forui.dart';
import '../../../../core/contracts/danmaku.dart';
import '../../../../providers/danmaku_settings_provider.dart';
import '../../../../shared/widgets/app_loading_ring.dart';
import '../danmaku/danmaku_match_tile.dart';
import '../player_drawer_panel.dart';
import 'panel_setting_rows.dart';
part 'danmaku_panel_body_view.dart';
extension DanmakuPanelSettingsCanvas on DanmakuPanelSettings {
DanmakuOption toDanmakuOption({double? screenHeight}) {
final double effectiveArea;
if (maxRows > 0 && screenHeight != null && screenHeight > 0) {
final lineHeightPx = _measureDanmakuLineHeight(
fontSize: fontSize,
lineHeight: lineHeight,
fontFamily: fontFamily,
);
final computed = (maxRows + 0.5) * lineHeightPx / screenHeight;
effectiveArea = computed.clamp(0.05, 1.0);
} else {
effectiveArea = area;
}
return DanmakuOption(
opacity: 1.0,
area: effectiveArea,
fontSize: fontSize,
fontWeight: bold ? 7 : 4,
fontFamily: fontFamily,
duration: 10.0 / speed.clamp(0.1, 5.0),
lineHeight: lineHeight,
hideTop: fixedToScroll ? true : !showTop,
hideScroll: !showScroll,
hideBottom: fixedToScroll ? true : !showBottom,
strokeWidth: strokeWidth,
safeArea: true,
massiveMode: false,
);
}
}
double _measureDanmakuLineHeight({
required double fontSize,
required double lineHeight,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: '弹幕',
style: TextStyle(
fontSize: fontSize,
height: lineHeight,
fontFamily: fontFamily,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.height;
}
enum _DanmakuView { main, settings, filter, search }
class DanmakuPanelBody extends StatefulWidget {
final DanmakuPanelSettings settings;
final ValueChanged<DanmakuPanelSettings> onChanged;
final DanmakuSessionState sessionState;
final bool danmakuGlobalEnabled;
final bool danmakuSourceConfigured;
final List<DanmakuMatchCandidate> matches;
final int selectedMatchIndex;
final ValueChanged<int> onMatchSelected;
final VoidCallback onOpenSearch;
final bool isLoading;
final int commentCount;
final List<String> blockedKeywords;
final ValueChanged<String> onAddKeyword;
final ValueChanged<String> onRemoveKeyword;
final VoidCallback? onRetry;
final VoidCallback? onGoToSettings;
final VoidCallback? onToggleDanmakuEnabled;
const DanmakuPanelBody({
super.key,
required this.settings,
required this.onChanged,
required this.sessionState,
this.danmakuGlobalEnabled = true,
this.danmakuSourceConfigured = true,
this.matches = const [],
this.selectedMatchIndex = -1,
required this.onMatchSelected,
required this.onOpenSearch,
this.isLoading = false,
this.commentCount = 0,
this.blockedKeywords = const [],
required this.onAddKeyword,
required this.onRemoveKeyword,
this.onRetry,
this.onGoToSettings,
this.onToggleDanmakuEnabled,
});
@override
State<DanmakuPanelBody> createState() => _DanmakuPanelBodyState();
}
class _DanmakuPanelBodyState extends State<DanmakuPanelBody> {
_DanmakuView _view = _DanmakuView.main;
bool _navForward = true;
DanmakuPanelSettings get s => widget.settings;
void _emit(DanmakuPanelSettings next) => widget.onChanged(next);
void _navigateTo(_DanmakuView view) {
if (view == _view) return;
setState(() {
_navForward = view != _DanmakuView.main;
_view = view;
});
}
void _back() => _navigateTo(_DanmakuView.main);
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final isEntering =
(child.key as ValueKey<_DanmakuView>).value == _view;
final enterOffset = _navForward
? const Offset(0.2, 0.0)
: const Offset(-0.2, 0.0);
final exitOffset = _navForward
? const Offset(-0.15, 0.0)
: const Offset(0.15, 0.0);
final slide = isEntering
? Tween<Offset>(
begin: enterOffset,
end: Offset.zero,
).animate(animation)
: Tween<Offset>(
begin: Offset.zero,
end: exitOffset,
).animate(animation);
return ClipRect(
child: SlideTransition(
position: slide,
child: FadeTransition(opacity: animation, child: child),
),
);
},
child: KeyedSubtree(
key: ValueKey<_DanmakuView>(_view),
child: _buildView(),
),
),
);
}
Widget _buildView() {
if (_view == _DanmakuView.main) {
final emptyState = _buildEmptyState();
if (emptyState != null) return emptyState;
}
return switch (_view) {
_DanmakuView.main => _buildMainMenu(),
_DanmakuView.settings => _buildSettingsDrawer(),
_DanmakuView.filter => _buildFilterDrawer(),
_DanmakuView.search => _buildSearchDrawer(),
};
}
final TextEditingController _keywordController = TextEditingController();
@override
void dispose() {
_keywordController.dispose();
super.dispose();
}
}
@@ -0,0 +1,745 @@
part of 'danmaku_panel_body.dart';
extension _DanmakuPanelBodyView on _DanmakuPanelBodyState {
Widget? _buildEmptyState() {
final state = widget.sessionState;
if (state is DanmakuSessionIdle) {
if (!widget.danmakuSourceConfigured) {
return _emptyStatePage(
icon: Icons.subtitles,
title: '弹幕未配置',
description: '请先在设置中配置弹幕数据源',
actions: [
if (widget.onGoToSettings != null)
_emptyStateCta('前往设置', widget.onGoToSettings!),
],
);
}
if (widget.danmakuGlobalEnabled) {
return _emptyStatePage(
icon: Icons.subtitles,
title: '正在搜索弹幕…',
showSpinner: true,
);
}
return _emptyStatePage(
icon: Icons.subtitles,
title: '弹幕已关闭',
description: '开启后将自动匹配弹幕',
actions: [
if (widget.onToggleDanmakuEnabled != null)
_emptyStateCta('开启弹幕', widget.onToggleDanmakuEnabled!),
],
);
}
if (state is DanmakuSessionMatching ||
(state is DanmakuSessionMatched &&
state.commentStatus == DanmakuCommentStatus.loading)) {
return _emptyStatePage(
icon: Icons.subtitles,
title: '正在搜索弹幕…',
showSpinner: true,
);
}
if (state is DanmakuSessionEmpty) {
return _emptyStatePage(
icon: Icons.search_off,
title: '未找到匹配弹幕',
description: '可尝试手动搜索匹配',
actions: [_emptyStateCta('搜索弹幕', widget.onOpenSearch)],
);
}
if (state is DanmakuSessionFailed) {
return _emptyStatePage(
icon: Icons.error_outline,
title: '加载失败',
description: '弹幕匹配请求出错',
actions: [
if (widget.onRetry != null) _emptyStateCta('重试', widget.onRetry!),
_emptyStateCta('搜索弹幕', widget.onOpenSearch),
],
);
}
return null;
}
Widget _emptyStatePage({
required IconData icon,
required String title,
String? description,
List<Widget> actions = const [],
bool showSpinner = false,
}) {
return SingleChildScrollView(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showSpinner)
Stack(
alignment: Alignment.center,
children: [
Icon(icon, color: const Color(0x61FFFFFF), size: 40),
const AppLoadingRing(size: 52, color: Color(0x99FFFFFF)),
],
)
else
Icon(icon, color: const Color(0x61FFFFFF), size: 40),
const SizedBox(height: 12),
Text(
title,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (description != null) ...[
const SizedBox(height: 4),
Text(
description,
style: const TextStyle(color: Colors.white38, fontSize: 12),
textAlign: TextAlign.center,
),
],
if (actions.isNotEmpty) ...[
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
actions[i],
],
],
),
],
],
),
),
);
}
Widget _emptyStateCta(String label, VoidCallback onTap) {
return TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.white12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: Text(label, style: const TextStyle(fontSize: 12)),
);
}
Widget _buildMainMenu() {
final hasMatch =
widget.selectedMatchIndex >= 0 &&
widget.selectedMatchIndex < widget.matches.length;
final matchTitle = hasMatch
? widget.matches[widget.selectedMatchIndex].animeTitle
: null;
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionHeader('弹幕数据'),
_menuItem(
icon: Icons.search,
label: '搜索弹幕',
hasCheck: hasMatch,
subtitle: matchTitle,
onTap: widget.onOpenSearch,
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsetsDirectional.only(start: 16)),
),
),
_sectionHeader('操作'),
_menuItem(
icon: Icons.tune,
label: '显示设置',
onTap: () => _navigateTo(_DanmakuView.settings),
),
_menuItem(
icon: Icons.filter_alt_outlined,
label: '过滤关键词',
badge: widget.blockedKeywords.isNotEmpty
? '${widget.blockedKeywords.length}'
: null,
onTap: () => _navigateTo(_DanmakuView.filter),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: PanelToggleRow(
label: '高能进度条',
value: s.heatmapEnabled,
onChanged: (v) => _emit(s.copyWith(heatmapEnabled: v)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: PanelToggleRow(
label: '弹幕',
value: s.enabled,
onChanged: (v) => _emit(s.copyWith(enabled: v)),
),
),
const SizedBox(height: 4),
],
),
);
}
Widget _sectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Text(
title,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.4,
),
),
);
}
Widget _menuItem({
required IconData icon,
required String label,
String? subtitle,
String? badge,
bool hasCheck = false,
bool showArrow = true,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
if (hasCheck)
const Icon(Icons.check, color: Colors.white70, size: 16)
else
Icon(icon, color: Colors.white54, size: 16),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
if (subtitle != null) ...[
const SizedBox(height: 1),
Text(
subtitle,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (badge != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(8),
),
child: Text(
badge,
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
),
const SizedBox(width: 4),
],
if (showArrow)
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
],
),
),
);
}
Widget _buildSettingsDrawer() {
return PlayerDrawerPanel(
title: '显示设置',
onBack: _back,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PanelToggleRow(
label: '固定弹幕转滚动',
value: s.fixedToScroll,
onChanged: (v) => _emit(s.copyWith(fixedToScroll: v)),
),
PanelStepperRow(
label: '弹幕行数',
display: s.maxRows == 0 ? '自动' : '${s.maxRows}',
onDecrement: s.maxRows > 0
? () => _emit(s.copyWith(maxRows: s.maxRows - 1))
: null,
onIncrement: s.maxRows < 10
? () => _emit(s.copyWith(maxRows: s.maxRows + 1))
: null,
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
const SizedBox(height: 6),
PanelSliderRow(
label: '延迟显示',
value: s.offset,
min: -10.0,
max: 10.0,
step: 0.5,
display:
'${s.offset >= 0 ? '+' : ''}${s.offset.toStringAsFixed(1)}s',
onChanged: (v) => _emit(s.copyWith(offset: _round(v, -10.0, 10.0))),
),
PanelSliderRow(
label: '行间距',
value: s.lineHeight,
min: 1.0,
max: 3.0,
step: 0.1,
display: s.lineHeight.toStringAsFixed(1),
onChanged: (v) =>
_emit(s.copyWith(lineHeight: _round(v, 1.0, 3.0))),
),
PanelSliderRow(
label: '基础速度',
value: s.speed,
min: 0.25,
max: 2.0,
step: 0.25,
display: '${s.speed.toStringAsFixed(2)}x',
onChanged: (v) => _emit(s.copyWith(speed: v.clamp(0.25, 2.0))),
),
PanelSliderRow(
label: '字号大小',
value: s.fontSize,
min: 12,
max: 40,
step: 2,
display: s.fontSize.round().toString(),
onChanged: (v) => _emit(s.copyWith(fontSize: _round(v, 12, 40))),
),
PanelToggleRow(
label: '粗体',
value: s.bold,
onChanged: (v) => _emit(s.copyWith(bold: v)),
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
const SizedBox(height: 6),
PanelSliderRow(
label: '不透明度',
value: s.opacity,
min: 0.1,
max: 1.0,
step: 0.1,
display: '${(s.opacity * 100).round()}%',
onChanged: (v) => _emit(s.copyWith(opacity: _round(v, 0.1, 1.0))),
),
PanelSliderRow(
label: '描边大小',
value: s.strokeWidth,
min: 0.0,
max: 3.0,
step: 0.5,
display: s.strokeWidth.toStringAsFixed(1),
onChanged: (v) =>
_emit(s.copyWith(strokeWidth: _round(v, 0.0, 3.0))),
),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
_fontFamilyRow(),
const SizedBox(height: 8),
_resetButton(),
const SizedBox(height: 4),
],
),
);
}
static double _round(double v, double min, double max) =>
(v.clamp(min, max) * 10).round() / 10;
static const _fontPresets = [
(label: '系统默认', value: null),
(label: 'PingFang SC', value: 'PingFang SC'),
(label: 'Heiti SC', value: 'Heiti SC'),
(label: 'Arial', value: 'Arial'),
];
Widget _fontFamilyRow() {
final current = s.fontFamily;
final label =
_fontPresets.where((p) => p.value == current).firstOrNull?.label ??
'系统默认';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const Expanded(
child: Text(
'字体',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
),
GestureDetector(
onTap: _showFontPicker,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(
color: Colors.lightBlueAccent,
fontSize: 13,
),
),
const SizedBox(width: 2),
const Icon(
Icons.unfold_more,
color: Colors.lightBlueAccent,
size: 14,
),
],
),
),
],
),
);
}
void _showFontPicker() {
showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF252525),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(14)),
),
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
'选择字体',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
for (final p in _fontPresets)
ListTile(
title: Text(
p.label,
style: TextStyle(
color: Colors.white,
fontFamily: p.value,
fontSize: 14,
),
),
trailing: s.fontFamily == p.value
? const Icon(Icons.check, color: Colors.white70, size: 18)
: null,
onTap: () {
Navigator.pop(context);
_emit(s.copyWith(fontFamily: p.value));
},
),
const SizedBox(height: 16),
],
),
);
}
Widget _resetButton() {
return SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => _emit(defaultDanmakuPanelSettings()),
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
backgroundColor: Colors.white10,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: const Text('重置', style: TextStyle(fontSize: 13)),
),
);
}
Widget _buildFilterDrawer() {
return PlayerDrawerPanel(
title: '弹幕过滤',
onBack: _back,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_keywordInput(),
const SizedBox(height: 8),
const FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.zero),
),
),
const SizedBox(height: 4),
_keywordList(),
const SizedBox(height: 4),
],
),
);
}
Widget _keywordInput() {
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12),
),
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(left: 10),
child: Icon(
Icons.filter_alt_outlined,
color: Colors.white38,
size: 16,
),
),
Expanded(
child: TextField(
controller: _keywordController,
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: const InputDecoration(
hintText: '输入要过滤的关键词',
hintStyle: TextStyle(color: Colors.white38, fontSize: 13),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
isDense: true,
),
onSubmitted: _addKeyword,
inputFormatters: [LengthLimitingTextInputFormatter(30)],
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 18),
color: Colors.white54,
padding: const EdgeInsets.only(right: 8),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => _addKeyword(_keywordController.text),
),
],
),
);
}
void _addKeyword(String raw) {
final kw = raw.trim();
if (kw.isEmpty) return;
if (widget.blockedKeywords.contains(kw)) {
_keywordController.clear();
return;
}
widget.onAddKeyword(kw);
_keywordController.clear();
}
Widget _keywordList() {
final keywords = widget.blockedKeywords;
if (keywords.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Text(
'暂无屏蔽关键词',
style: TextStyle(color: Colors.white38, fontSize: 12),
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final kw in keywords)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.label_outline,
color: Colors.white38,
size: 14,
),
const SizedBox(width: 8),
Expanded(
child: Text(
kw,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.close, size: 14),
color: Colors.white38,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
onPressed: () => widget.onRemoveKeyword(kw),
),
],
),
),
],
);
}
Widget _buildSearchDrawer() {
return PlayerDrawerPanel(
title: '搜索弹幕',
onBack: _back,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_matchesContent(),
const SizedBox(height: 8),
_manualSearchTrigger(),
const SizedBox(height: 4),
],
),
);
}
Widget _matchesContent() {
if (widget.isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppLoadingRing(size: 14, color: Colors.white54),
SizedBox(width: 8),
Text(
'正在匹配弹幕源…',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
);
}
final matches = widget.matches;
if (matches.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.white24, size: 14),
SizedBox(width: 6),
Expanded(
child: Text(
'未匹配到弹幕源,可手动搜索',
style: TextStyle(color: Colors.white38, fontSize: 12),
),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
'弹幕源 (${matches.length} 条)',
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
),
...List.generate(matches.length, (i) {
final m = matches[i];
final selected = i == widget.selectedMatchIndex;
return DanmakuMatchTile(
candidate: m,
selected: selected,
onTap: () => widget.onMatchSelected(i),
);
}),
],
);
}
Widget _manualSearchTrigger() {
return SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: widget.onOpenSearch,
style: OutlinedButton.styleFrom(
alignment: Alignment.centerLeft,
foregroundColor: Colors.white,
side: const BorderSide(color: Color(0x664FA8FF)),
backgroundColor: const Color(0x10FFFFFF),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
icon: const Icon(Icons.manage_search, size: 16),
label: const Row(
children: [
Expanded(
child: Text(
'搜索并手动选择分集',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
Icon(Icons.chevron_right, size: 14, color: Colors.white38),
],
),
),
);
}
}
@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/contracts/library.dart';
import '../../../../providers/di_providers.dart';
import '../../../../providers/tmdb_settings_provider.dart';
import '../../../../shared/utils/emby_ticks.dart';
import '../../../../shared/utils/format_utils.dart';
import '../../../../shared/utils/tmdb_episode_helpers.dart';
import '../../../../shared/utils/tmdb_key_utils.dart';
import '../../../../shared/widgets/app_inline_alert.dart';
import '../../../../shared/widgets/app_loading_ring.dart';
import '../../../../shared/widgets/episode_thumb.dart';
import '../../../../shared/widgets/season_selector.dart';
import '../player_panel_shell.dart';
const Color _kActiveColor = Color(0xFF4FA8FF);
class EpisodePanelBody extends ConsumerStatefulWidget {
final String seriesId;
final String? initialSeasonId;
final String currentEpisodeId;
final ValueChanged<EmbyRawItem> onEpisodeSelected;
final String serverId;
final String baseUrl;
final String token;
const EpisodePanelBody({
super.key,
required this.seriesId,
required this.currentEpisodeId,
required this.onEpisodeSelected,
required this.serverId,
required this.baseUrl,
required this.token,
this.initialSeasonId,
});
@override
ConsumerState<EpisodePanelBody> createState() => EpisodePanelBodyState();
}
class EpisodePanelBodyState extends ConsumerState<EpisodePanelBody> {
String? _selectedSeasonId;
bool _loadStarted = false;
@override
void initState() {
super.initState();
}
void ensureLoaded() {
if (_loadStarted) return;
_loadStarted = true;
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
if (!_loadStarted) {
return const SizedBox.shrink();
}
final serverId = widget.serverId;
if (serverId.isEmpty) return const SizedBox.shrink();
final seasonsAsync = ref.watch(
seriesSeasonsDataProvider((
serverId: serverId,
seriesId: widget.seriesId,
)),
);
return seasonsAsync.when(
loading: _loading,
error: (_, _) => _error(),
data: (seasons) {
if (seasons.isEmpty) return _empty('暂无可用季');
final effectiveSeasonId = _resolveSeasonId(seasons);
final effectiveSeason = seasons.firstWhere(
(s) => s.Id == effectiveSeasonId,
orElse: () => seasons.first,
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (seasons.length > 1) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Align(
alignment: Alignment.centerLeft,
child: SeasonSelector(
seasons: seasons,
selectedId: effectiveSeasonId,
onSelect: (seasonId) {
setState(() => _selectedSeasonId = seasonId);
},
),
),
),
const Divider(color: kPanelDivider, height: 1),
],
Flexible(
child: _EpisodeList(
seriesId: widget.seriesId,
seasonId: effectiveSeasonId,
seasonNumber: seasonNumberFrom(effectiveSeason),
currentEpisodeId: widget.currentEpisodeId,
serverId: widget.serverId,
baseUrl: widget.baseUrl,
token: widget.token,
onSelected: widget.onEpisodeSelected,
),
),
],
);
},
);
}
String _resolveSeasonId(List<EmbyRawSeason> seasons) {
final selected = _selectedSeasonId;
if (selected != null && seasons.any((s) => s.Id == selected)) {
return selected;
}
final initial = widget.initialSeasonId;
if (initial != null && seasons.any((s) => s.Id == initial)) {
return initial;
}
return seasons.first.Id;
}
Widget _loading() => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing(size: 22, color: Colors.white)),
);
Widget _empty(String text) => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 13),
),
),
);
Widget _error() => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: AppInlineAlert(message: '选集加载失败'),
);
}
class _EpisodeList extends ConsumerStatefulWidget {
final String seriesId;
final String seasonId;
final int? seasonNumber;
final String? currentEpisodeId;
final String serverId;
final String baseUrl;
final String token;
final ValueChanged<EmbyRawItem> onSelected;
const _EpisodeList({
required this.seriesId,
required this.seasonId,
required this.seasonNumber,
required this.currentEpisodeId,
required this.serverId,
required this.baseUrl,
required this.token,
required this.onSelected,
});
@override
ConsumerState<_EpisodeList> createState() => _EpisodeListState();
}
class _EpisodeListState extends ConsumerState<_EpisodeList> {
final ScrollController _scrollController = ScrollController();
bool _hasScrolledToActive = false;
static const _itemHeight = 76.0;
@override
void didUpdateWidget(covariant _EpisodeList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.seasonId != widget.seasonId ||
oldWidget.currentEpisodeId != widget.currentEpisodeId) {
_hasScrolledToActive = false;
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _scrollToActive(List<EmbyRawItem> episodes) {
if (_hasScrolledToActive || widget.currentEpisodeId == null) return;
final idx = episodes.indexWhere((e) => e.Id == widget.currentEpisodeId);
if (idx < 0) return;
_hasScrolledToActive = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return;
final viewport = _scrollController.position.viewportDimension;
final target = idx * _itemHeight - (viewport - _itemHeight) / 2;
final clamped = target.clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(clamped);
});
}
@override
Widget build(BuildContext context) {
final serverId = widget.serverId;
final episodesAsync = ref.watch(
seriesEpisodesDataProvider((
serverId: serverId,
seriesId: widget.seriesId,
seasonId: widget.seasonId,
)),
);
final tmdbSettings = ref.watch(tmdbSettingsProvider).value;
final canRequest = tmdbSettings?.canRequest ?? false;
String tmdbSeriesId = '';
if (canRequest) {
final seriesItem = ref
.watch(
mediaDetailDataProvider((
serverId: serverId,
itemId: widget.seriesId,
)),
)
.value
?.base;
if (seriesItem != null) tmdbSeriesId = tmdbIdFromItem(seriesItem);
}
final canUseTmdbSeason =
canRequest && tmdbSeriesId.isNotEmpty && widget.seasonNumber != null;
final tmdbSeason = canUseTmdbSeason
? ref
.watch(
tmdbSeasonDetailProvider((
tmdbId: tmdbSeriesId,
seasonNumber: widget.seasonNumber!,
)),
)
.value
: null;
final tmdbStillUrls = tmdbStillUrlsByEpisode(
tmdbSeason,
tmdbSettings?.effectiveImageBaseUrl,
);
return episodesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing(size: 22, color: Colors.white)),
),
error: (_, _) => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: AppInlineAlert(message: '选集加载失败'),
),
data: (episodes) {
if (episodes.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'暂无分集',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
),
);
}
_scrollToActive(episodes);
return ListView.separated(
controller: _scrollController,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: episodes.length,
separatorBuilder: (_, _) => const Divider(
color: kPanelDivider,
height: 1,
indent: 12,
endIndent: 12,
),
itemBuilder: (_, i) {
final ep = episodes[i];
final tmdbStillUrl = ep.IndexNumber == null
? null
: tmdbStillUrls[ep.IndexNumber];
return _EpisodeTile(
episode: ep,
seriesId: widget.seriesId,
tmdbStillUrl: tmdbStillUrl,
isActive: ep.Id == widget.currentEpisodeId,
baseUrl: widget.baseUrl,
token: widget.token,
onTap: () => widget.onSelected(ep),
);
},
);
},
);
}
}
class _EpisodeTile extends StatelessWidget {
final EmbyRawItem episode;
final String seriesId;
final String? tmdbStillUrl;
final bool isActive;
final String baseUrl;
final String token;
final VoidCallback onTap;
const _EpisodeTile({
required this.episode,
required this.seriesId,
required this.tmdbStillUrl,
required this.isActive,
required this.baseUrl,
required this.token,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final ep = formatEpisodeNumber(episode.IndexNumber) ?? '';
final runtime = episode.RunTimeTicks != null
? '${(episode.RunTimeTicks! / kTicksPerMinute).round()} 分钟'
: '';
final progress = playbackProgress(episode);
return InkWell(
onTap: isActive ? null : onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 96,
height: 54,
color: Colors.white12,
child: Stack(
fit: StackFit.expand,
children: [
EpisodeThumb(
episode: episode,
seriesId: seriesId,
baseUrl: baseUrl,
token: token,
tmdbStillUrl: tmdbStillUrl,
maxWidth: 240,
),
if (isActive)
Container(
color: Colors.black54,
alignment: Alignment.center,
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 28,
),
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(
_kActiveColor,
),
),
),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (ep.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: isActive ? _kActiveColor : Colors.white12,
borderRadius: BorderRadius.circular(3),
),
child: Text(
ep,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
],
Expanded(
child: Text(
episode.Name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isActive ? _kActiveColor : Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 4),
if (runtime.isNotEmpty)
Text(
runtime,
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../player_panel_shell.dart' show panelDivider;
import 'selection_panel_entry.dart';
class PanelSelectionList<T> extends StatelessWidget {
final List<SelectionPanelEntry<T>> entries;
final ValueChanged<T> onSelected;
const PanelSelectionList({
super.key,
required this.entries,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in entries)
if (entry.isDivider) panelDivider() else _itemTile(entry),
],
),
);
}
Widget _itemTile(SelectionPanelEntry<T> entry) {
return GestureDetector(
onTap: () {
if (entry.value != null) onSelected(entry.value as T);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
SizedBox(
width: 24,
child: entry.isActive
? const Icon(Icons.check, size: 16, color: Color(0xFF2196F3))
: null,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
entry.label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: entry.isActive
? const Color(0xFF2196F3)
: Colors.white,
fontWeight: entry.isActive
? FontWeight.w600
: FontWeight.normal,
fontSize: 13,
),
),
if (entry.subtitle != null && entry.subtitle != entry.label)
Text(
entry.subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
),
],
),
),
if (entry.badge != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: Text(
entry.badge!,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
),
],
if (entry.isLoading) ...[
const SizedBox(width: 8),
const SizedBox.square(
dimension: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Color(0xFF2196F3),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
const _kRowLabelStyle = TextStyle(color: Colors.white70, fontSize: 13);
const _kRowValueStyle = TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
);
const _kPanelSliderTheme = SliderThemeData(
trackHeight: 3,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: RoundSliderOverlayShape(overlayRadius: 14),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.white10,
);
class PanelGroupHeader extends StatelessWidget {
final String title;
const PanelGroupHeader({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4, left: 2),
child: Text(
title,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
class PanelSliderRow extends StatelessWidget {
final String label;
final double value;
final double min;
final double max;
final double step;
final String display;
final ValueChanged<double> onChanged;
const PanelSliderRow({
super.key,
required this.label,
required this.value,
required this.min,
required this.max,
required this.step,
required this.display,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final divisions = ((max - min) / step).round();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: Text(label, style: _kRowLabelStyle)),
Text(display, style: _kRowValueStyle),
],
),
SizedBox(
height: 28,
child: SliderTheme(
data: _kPanelSliderTheme,
child: Slider(
value: value.clamp(min, max),
min: min,
max: max,
divisions: divisions,
padding: EdgeInsets.zero,
onChanged: onChanged,
),
),
),
],
),
);
}
}
class PanelToggleRow extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const PanelToggleRow({
super.key,
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(child: Text(label, style: _kRowLabelStyle)),
FSwitch(
value: value,
onChange: onChanged,
style: FSwitchStyleDelta.delta(
trackColor: FVariantsValueDelta.delta([
FVariantValueDeltaOperation.all(Colors.white12),
FVariantValueDeltaOperation.exact({
FSwitchVariantConstraint.selected,
}, Colors.white38),
]),
thumbColor: FVariantsValueDelta.delta([
FVariantValueDeltaOperation.all(Colors.white38),
FVariantValueDeltaOperation.exact({
FSwitchVariantConstraint.selected,
}, Colors.white),
]),
),
),
],
),
);
}
}
class PanelStepperRow extends StatelessWidget {
final String label;
final String display;
final VoidCallback? onDecrement;
final VoidCallback? onIncrement;
const PanelStepperRow({
super.key,
required this.label,
required this.display,
this.onDecrement,
this.onIncrement,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(child: Text(label, style: _kRowLabelStyle)),
_StepButton(icon: Icons.remove, onPressed: onDecrement),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 46,
child: Text(
display,
style: _kRowValueStyle,
textAlign: TextAlign.center,
),
),
),
_StepButton(icon: Icons.add, onPressed: onIncrement),
],
),
);
}
}
class _StepButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
const _StepButton({required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 28,
child: FButton.icon(
variant: FButtonVariant.ghost,
size: FButtonSizeVariant.xs,
onPress: onPressed,
child: Icon(
icon,
size: 14,
color: onPressed != null ? Colors.white70 : Colors.white24,
),
),
);
}
}
@@ -0,0 +1,27 @@
class SelectionPanelEntry<T> {
final T? value;
final String? label;
final String? subtitle;
final String? badge;
final bool isActive;
final bool isLoading;
final bool isDivider;
const SelectionPanelEntry.item({
required T this.value,
required String this.label,
this.subtitle,
this.badge,
this.isActive = false,
this.isLoading = false,
}) : isDivider = false;
const SelectionPanelEntry.divider()
: value = null,
label = null,
subtitle = null,
badge = null,
isActive = false,
isLoading = false,
isDivider = true;
}
@@ -0,0 +1,513 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../../core/contracts/playback.dart';
import '../../../../core/infra/player_engine/player_engine.dart';
import '../../../../providers/subtitle_settings_provider.dart';
import '../player_drawer_panel.dart';
import '../player_panel_shell.dart';
import 'panel_selection_list.dart';
import 'panel_setting_rows.dart';
import 'selection_panel_entry.dart';
const _activeColor = Color(0xFF2196F3);
String? _translateLanguage(String? code) {
if (code == null || code.isEmpty) return null;
return kLanguageNames[code.toLowerCase()];
}
enum _SubtitlePanelView { subtitles, style }
class SubtitlePanelBody extends StatefulWidget {
final SubtitleStyleSettings subtitleSettings;
final ValueChanged<SubtitleStyleSettings> onSubtitleSettingsChanged;
final List<EmbySubtitleTrack> subtitles;
final ValueListenable<int?> activeSubtitleStreamIndex;
final ValueListenable<bool> subtitleLoading;
final void Function(int? streamIndex)? onSubtitleSelected;
const SubtitlePanelBody({
super.key,
required this.subtitleSettings,
required this.onSubtitleSettingsChanged,
required this.subtitles,
required this.activeSubtitleStreamIndex,
required this.subtitleLoading,
this.onSubtitleSelected,
});
@override
State<SubtitlePanelBody> createState() => _SubtitlePanelBodyState();
}
class _SubtitlePanelBodyState extends State<SubtitlePanelBody> {
_SubtitlePanelView _selectedView = _SubtitlePanelView.subtitles;
int? _activeStreamIndex;
bool _isSubtitleLoading = false;
@override
void initState() {
super.initState();
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
widget.subtitleLoading.addListener(_onLoadingChanged);
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
_isSubtitleLoading = widget.subtitleLoading.value;
}
@override
void didUpdateWidget(covariant SubtitlePanelBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.activeSubtitleStreamIndex !=
widget.activeSubtitleStreamIndex) {
oldWidget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged);
_activeStreamIndex = widget.activeSubtitleStreamIndex.value;
}
if (oldWidget.subtitleLoading != widget.subtitleLoading) {
oldWidget.subtitleLoading.removeListener(_onLoadingChanged);
widget.subtitleLoading.addListener(_onLoadingChanged);
_isSubtitleLoading = widget.subtitleLoading.value;
}
}
@override
void dispose() {
widget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged);
widget.subtitleLoading.removeListener(_onLoadingChanged);
super.dispose();
}
void _onActiveIndexChanged() {
if (!mounted) return;
setState(() => _activeStreamIndex = widget.activeSubtitleStreamIndex.value);
}
void _onLoadingChanged() {
if (!mounted) return;
setState(() => _isSubtitleLoading = widget.subtitleLoading.value);
}
void _selectSubtitle(int? streamIndex) {
if (!mounted) return;
setState(() => _activeStreamIndex = streamIndex);
widget.onSubtitleSelected?.call(streamIndex);
}
@override
Widget build(BuildContext context) {
if (Platform.isAndroid) return _buildAndroidBody();
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 150, child: _leftMenu()),
const FDivider(
axis: Axis.vertical,
style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)),
),
Expanded(child: _content()),
],
);
}
Widget _buildAndroidBody() {
if (_selectedView == _SubtitlePanelView.style) {
return PlayerDrawerPanel(
title: '字幕样式',
onBack: () =>
setState(() => _selectedView = _SubtitlePanelView.subtitles),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _subtitleStyleRows(),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_androidStyleEntry(),
panelDivider(),
Flexible(child: _subtitleList()),
],
);
}
Widget _androidStyleEntry() {
return InkWell(
onTap: () => setState(() => _selectedView = _SubtitlePanelView.style),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
const Icon(
Icons.closed_caption_outlined,
color: Colors.white54,
size: 16,
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'字幕样式',
style: TextStyle(color: Colors.white, fontSize: 13),
),
),
Text(
_subtitleStyleLabel(),
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
],
),
),
);
}
Widget _content() {
switch (_selectedView) {
case _SubtitlePanelView.subtitles:
return _subtitleList();
case _SubtitlePanelView.style:
return _subtitleSettings();
}
}
Widget _leftMenu() {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_menuItem(
view: _SubtitlePanelView.subtitles,
label: '字幕',
subtitle: _activeSubtitleLabel(),
icon: Icons.subtitles_outlined,
),
_menuItem(
view: _SubtitlePanelView.style,
label: '字幕样式',
subtitle: _subtitleStyleLabel(),
icon: Icons.closed_caption_outlined,
),
],
),
);
}
Widget _menuItem({
required _SubtitlePanelView view,
required String label,
required String subtitle,
required IconData icon,
}) {
final selected = _selectedView == view;
return GestureDetector(
onTap: () => setState(() => _selectedView = view),
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: selected
? BoxDecoration(
color: Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
)
: null,
child: Row(
children: [
Icon(
icon,
size: 16,
color: selected ? _activeColor : Colors.white54,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: selected ? _activeColor : Colors.white,
fontSize: 13,
fontWeight: selected
? FontWeight.w600
: FontWeight.normal,
),
),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: selected
? _activeColor.withOpacity(0.7)
: Colors.white38,
fontSize: 11,
),
),
],
),
),
Icon(
Icons.chevron_right,
size: 16,
color: selected ? _activeColor : Colors.white24,
),
],
),
),
);
}
Widget _subtitleList() {
final embedded = widget.subtitles
.where((track) => !track.isExternal)
.toList(growable: false);
final external = widget.subtitles
.where((track) => track.isExternal)
.toList(growable: false);
const disableValue = -1;
final entries = <SelectionPanelEntry<int>>[
SelectionPanelEntry.item(
value: disableValue,
label: '禁用',
isActive: _activeStreamIndex == null,
),
if (embedded.isNotEmpty) const SelectionPanelEntry.divider(),
for (final track in embedded)
SelectionPanelEntry.item(
value: track.index,
label: track.title ?? track.language ?? '字幕 ${track.index}',
subtitle: _translateLanguage(track.language),
isActive: _activeStreamIndex == track.index,
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
),
if (external.isNotEmpty) const SelectionPanelEntry.divider(),
for (final track in external)
SelectionPanelEntry.item(
value: track.index,
label: track.title ?? track.language ?? '外挂字幕 ${track.index}',
subtitle: _translateLanguage(track.language),
badge: '外挂',
isActive: _activeStreamIndex == track.index,
isLoading: _isSubtitleLoading && _activeStreamIndex == track.index,
),
];
return PanelSelectionList<int>(
entries: entries,
onSelected: (value) =>
_selectSubtitle(value == disableValue ? null : value),
);
}
Widget _subtitleSettings() {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PanelGroupHeader(title: '主字幕样式'),
..._subtitleStyleRows(),
const SizedBox(height: 8),
],
),
);
}
List<Widget> _subtitleStyleRows() {
final settings = widget.subtitleSettings;
return [
PanelSliderRow(
label: '位置',
value: settings.position.toDouble(),
min: 0,
max: 150,
step: 5,
display: settings.position.toString(),
onChanged: (v) => widget.onSubtitleSettingsChanged(
settings.copyWith(position: v.round()),
),
),
PanelSliderRow(
label: '大小',
value: settings.fontSize,
min: 10,
max: 120,
step: 2,
display: settings.fontSize.round().toString(),
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(fontSize: v)),
),
PanelSliderRow(
label: '延迟',
value: settings.delay,
min: -10.0,
max: 10.0,
step: 0.5,
display:
'${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s',
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(delay: v)),
),
PanelSliderRow(
label: '背景',
value: settings.bgOpacity,
min: 0.0,
max: 1.0,
step: 0.1,
display: '${(settings.bgOpacity * 100).round()}%',
onChanged: (v) =>
widget.onSubtitleSettingsChanged(settings.copyWith(bgOpacity: v)),
),
];
}
String _activeSubtitleLabel() {
final index = _activeStreamIndex;
if (index == null) return '禁用';
final track = widget.subtitles
.where((subtitle) => subtitle.index == index)
.firstOrNull;
if (track == null) return '字幕';
return track.title ?? track.language ?? '字幕 ${track.index}';
}
String _subtitleStyleLabel() {
final settings = widget.subtitleSettings;
return '${settings.fontSize.round()} / ${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s';
}
}
class AudioPanelBody extends StatefulWidget {
final PlayerEngine player;
final void Function(AudioTrack track)? onAudioTrackSelected;
const AudioPanelBody({
super.key,
required this.player,
this.onAudioTrackSelected,
});
@override
State<AudioPanelBody> createState() => _AudioPanelBodyState();
}
class _AudioPanelBodyState extends State<AudioPanelBody> {
AudioTrack _activeAudio = const AudioTrack.auto();
List<AudioTrack> _audioTracks = const [];
StreamSubscription<PlayerTrack>? _trackSub;
StreamSubscription<PlayerTracks>? _tracksSub;
@override
void initState() {
super.initState();
_syncFromPlayer(widget.player);
_subscribePlayer(widget.player);
}
@override
void didUpdateWidget(covariant AudioPanelBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
_cancelSubscriptions();
_syncFromPlayer(widget.player);
_subscribePlayer(widget.player);
}
}
@override
void dispose() {
_cancelSubscriptions();
super.dispose();
}
void _syncFromPlayer(PlayerEngine player) {
_activeAudio = player.state.track.audio;
_audioTracks = player.state.tracks.embeddedAudio;
}
void _subscribePlayer(PlayerEngine player) {
_trackSub = player.stream.track.listen((track) {
if (!mounted) return;
setState(() => _activeAudio = track.audio);
});
_tracksSub = player.stream.tracks.listen((tracks) {
if (!mounted) return;
setState(() => _audioTracks = tracks.embeddedAudio);
});
}
void _cancelSubscriptions() {
unawaited(_trackSub?.cancel());
unawaited(_tracksSub?.cancel());
_trackSub = null;
_tracksSub = null;
}
Future<void> _selectAudio(AudioTrack track) async {
if (!mounted) return;
setState(() => _activeAudio = track);
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
try {
await widget.player.setAudioTrack(track);
widget.onAudioTrackSelected?.call(track);
} catch (_) {
if (mounted) {
setState(() => _activeAudio = widget.player.state.track.audio);
}
}
}
@override
Widget build(BuildContext context) {
return _audioList();
}
Widget _audioList() {
final entries = <SelectionPanelEntry<AudioTrack>>[
for (final t in _audioTracks)
SelectionPanelEntry.item(
value: t,
label: t.title ?? t.language ?? '音频 ${t.id}',
subtitle: _translateLanguage(t.language) ?? t.language,
isActive: _activeAudio.id == t.id,
),
if (_audioTracks.isNotEmpty) const SelectionPanelEntry.divider(),
SelectionPanelEntry.item(
value: const AudioTrack.no(),
label: '关闭音频',
isActive: _activeAudio.id == 'no',
),
];
return PanelSelectionList<AudioTrack>(
entries: entries,
onSelected: (track) => unawaited(_selectAudio(track)),
);
}
}