746 lines
22 KiB
Dart
746 lines
22 KiB
Dart
|
|
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),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|