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