Files
sm-emby-share/lib/features/settings/widgets/danmaku_sources_dialog.dart
T

789 lines
25 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/contracts/danmaku.dart';
import '../../../providers/danmaku_settings_provider.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/theme/app_theme_scope.dart';
import '../../../shared/theme/app_theme.dart';
String danmakuSourcesSummary(List<DanmakuSource> sources) {
if (sources.isEmpty) return '未配置';
final enabled = sources.where((s) => s.enabled).length;
if (enabled == 0) return '已配置 ${sources.length} 个,全部停用';
final first = sources.firstWhere(
(s) => s.enabled,
orElse: () => sources.first,
);
return '$enabled/${sources.length} 个启用,优先:${first.displayName}';
}
void showDanmakuSourcesDialog(
BuildContext context,
List<DanmakuSource> sources,
) {
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
if (isCompact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _DanmakuSourcesSheet(initialSources: sources),
);
} else {
showFSheet<void>(
context: context,
side: FLayout.rtl,
mainAxisMaxRatio: 0.42,
draggable: false,
builder: (_) => AppThemeScope(
child: _DanmakuSourcesDesktopSheet(initialSources: sources),
),
);
}
}
mixin _DanmakuSourcesLogic<T extends ConsumerStatefulWidget>
on ConsumerState<T> {
late List<DanmakuSource> sources;
void initSources(List<DanmakuSource> initial) {
sources = List.of(initial);
}
Future<void> persist(List<DanmakuSource> next) async {
final normalized = await ref
.read(danmakuSettingsProvider.notifier)
.setSources(next);
if (!mounted) return;
setState(() => sources = normalized);
}
Future<void> showEditDialog(DanmakuSource? source) async {
final nameCtrl = TextEditingController(text: source?.name ?? '');
final urlCtrl = TextEditingController(text: source?.url ?? '');
try {
final saved = await showFDialog<DanmakuSource>(
context: context,
builder: (ctx, _, animation) => AppThemeScope(
child: FDialog.adaptive(
animation: animation,
title: Text(source == null ? '添加弹幕源' : '编辑弹幕源'),
body: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: '名称',
hintText: '例如:主源、备用源',
isDense: true,
),
),
const SizedBox(height: 12),
TextField(
controller: urlCtrl,
autofocus: source == null,
decoration: const InputDecoration(
labelText: '服务地址',
hintText: 'http://localhost:8080',
isDense: true,
),
),
],
),
),
actions: [
FButton(
onPress: () {
final url = urlCtrl.text.trim();
if (url.isEmpty) return;
final id = source?.id.isNotEmpty == true
? source!.id
: 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}';
Navigator.pop(
ctx,
DanmakuSource(
id: id,
name: nameCtrl.text.trim(),
url: url,
enabled: source?.enabled ?? true,
),
);
},
child: const Text('保存'),
),
FButton(
onPress: () => Navigator.pop(ctx),
variant: FButtonVariant.outline,
child: const Text('取消'),
),
],
),
),
);
if (saved == null) return;
final existing = sources.indexWhere((s) => s.id == saved.id);
final next = [...sources];
if (existing >= 0) {
next[existing] = saved;
} else {
next.add(saved);
}
await persist(next);
} finally {
nameCtrl.dispose();
urlCtrl.dispose();
}
}
Future<void> removeSource(DanmakuSource source) async {
await persist(sources.where((s) => s.id != source.id).toList());
}
Future<void> toggleSource(DanmakuSource source, bool enabled) async {
await persist(
sources
.map((s) => s.id == source.id ? s.copyWith(enabled: enabled) : s)
.toList(),
);
}
Future<void> reorderSource(int oldIndex, int newIndex) async {
final next = [...sources];
if (newIndex > oldIndex) newIndex -= 1;
final item = next.removeAt(oldIndex);
next.insert(newIndex, item);
await persist(next);
}
}
class _DanmakuSourcesSheet extends ConsumerStatefulWidget {
final List<DanmakuSource> initialSources;
const _DanmakuSourcesSheet({required this.initialSources});
@override
ConsumerState<_DanmakuSourcesSheet> createState() =>
_DanmakuSourcesSheetState();
}
class _DanmakuSourcesSheetState extends ConsumerState<_DanmakuSourcesSheet>
with _DanmakuSourcesLogic<_DanmakuSourcesSheet> {
bool _editing = false;
@override
void initState() {
super.initState();
initSources(widget.initialSources);
}
@override
Future<void> persist(List<DanmakuSource> next) async {
await super.persist(next);
if (mounted && sources.isEmpty && _editing) {
setState(() => _editing = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 8, 0),
child: Row(
children: [
Text(
'弹幕源',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
if (sources.isNotEmpty)
TextButton(
onPressed: () => setState(() => _editing = !_editing),
child: Text(_editing ? '完成' : '编辑'),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
if (sources.isNotEmpty)
Flexible(
child: _editing
? ReorderableListView.builder(
shrinkWrap: true,
buildDefaultDragHandles: false,
itemCount: sources.length,
onReorder: (oldIdx, newIdx) =>
unawaited(reorderSource(oldIdx, newIdx)),
itemBuilder: (context, index) {
final source = sources[index];
return _DanmakuEditRow(
key: ValueKey(source.id),
source: source,
index: index,
onDelete: () => unawaited(removeSource(source)),
onEdit: () => unawaited(showEditDialog(source)),
);
},
)
: ListView.builder(
shrinkWrap: true,
itemCount: sources.length,
itemBuilder: (context, index) {
final source = sources[index];
final hasName = source.name.trim().isNotEmpty;
return InkWell(
onTap: () => unawaited(showEditDialog(source)),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
hasName
? source.name.trim()
: source.url,
style: theme.textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasName)
Text(
source.url,
style: theme.textTheme.bodySmall
?.copyWith(
color: tokens.mutedForeground,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
FSwitch(
value: source.enabled,
onChange: (v) =>
unawaited(toggleSource(source, v)),
),
],
),
),
);
},
),
),
Padding(
padding: EdgeInsets.fromLTRB(
8,
4,
8,
MediaQuery.paddingOf(context).bottom + 16,
),
child: SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: () => unawaited(showEditDialog(null)),
icon: const Icon(Icons.add, size: 16),
label: const Text('添加弹幕源'),
),
),
),
],
),
);
}
}
class _DanmakuEditRow extends StatefulWidget {
final DanmakuSource source;
final int index;
final VoidCallback onDelete;
final VoidCallback onEdit;
const _DanmakuEditRow({
required super.key,
required this.source,
required this.index,
required this.onDelete,
required this.onEdit,
});
@override
State<_DanmakuEditRow> createState() => _DanmakuEditRowState();
}
class _DanmakuEditRowState extends State<_DanmakuEditRow>
with SingleTickerProviderStateMixin {
static const _deleteWidth = 72.0;
late final AnimationController _controller;
late final Animation<Offset> _slideAnim;
bool _expanded = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
_slideAnim = Tween<Offset>(
begin: Offset.zero,
end: const Offset(-0.2, 0),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() => _expanded = !_expanded);
if (_expanded) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final source = widget.source;
final hasName = source.name.trim().isNotEmpty;
return ClipRect(
child: SizedBox(
height: hasName ? 64 : 52,
child: Stack(
children: [
Positioned(
right: 0,
top: 0,
bottom: 0,
width: _deleteWidth,
child: Material(
color: Colors.red,
child: InkWell(
onTap: widget.onDelete,
child: const Center(
child: Text(
'删除',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
),
),
),
SlideTransition(
position: _slideAnim,
child: Container(
color: theme.colorScheme.surface,
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
GestureDetector(
onTap: _toggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AnimatedRotation(
turns: _expanded ? 0.0 : -0.25,
duration: const Duration(milliseconds: 250),
child: const Icon(
Icons.remove_circle,
color: Colors.red,
size: 22,
),
),
),
),
Expanded(
child: GestureDetector(
onTap: widget.onEdit,
behavior: HitTestBehavior.opaque,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
hasName ? source.name.trim() : source.url,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasName)
Text(
source.url,
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
ReorderableDragStartListener(
index: widget.index,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Icon(
Icons.drag_handle,
size: 18,
color: tokens.mutedForeground,
),
),
),
],
),
),
),
],
),
),
);
}
}
class _DanmakuSourcesDesktopSheet extends ConsumerStatefulWidget {
final List<DanmakuSource> initialSources;
const _DanmakuSourcesDesktopSheet({required this.initialSources});
@override
ConsumerState<_DanmakuSourcesDesktopSheet> createState() =>
_DanmakuSourcesDesktopSheetState();
}
class _DanmakuSourcesDesktopSheetState
extends ConsumerState<_DanmakuSourcesDesktopSheet>
with _DanmakuSourcesLogic<_DanmakuSourcesDesktopSheet> {
bool _editing = false;
DanmakuSource? _editingSource;
final _nameCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
@override
void initState() {
super.initState();
initSources(widget.initialSources);
}
@override
void dispose() {
_nameCtrl.dispose();
_urlCtrl.dispose();
super.dispose();
}
void _startEdit(DanmakuSource? source) {
_nameCtrl.text = source?.name ?? '';
_urlCtrl.text = source?.url ?? '';
setState(() {
_editing = true;
_editingSource = source;
});
}
void _cancelEdit() {
setState(() {
_editing = false;
_editingSource = null;
});
}
Future<void> _saveEdit() async {
final url = _urlCtrl.text.trim();
if (url.isEmpty) return;
final source = _editingSource;
final id = source?.id.isNotEmpty == true
? source!.id
: 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}';
final saved = DanmakuSource(
id: id,
name: _nameCtrl.text.trim(),
url: url,
enabled: source?.enabled ?? true,
);
final existing = sources.indexWhere((s) => s.id == saved.id);
final next = [...sources];
if (existing >= 0) {
next[existing] = saved;
} else {
next.add(saved);
}
await persist(next);
if (!mounted) return;
setState(() {
_editing = false;
_editingSource = null;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surface,
child: SafeArea(
left: false,
right: false,
child: Column(
children: [
_buildHeader(theme),
const Divider(height: 1),
Expanded(child: _editing ? _buildEditView() : _buildListView(theme)),
],
),
),
);
}
Widget _buildHeader(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
children: [
if (_editing)
IconButton(
tooltip: '返回',
icon: const Icon(Icons.arrow_back, size: 20),
onPressed: _cancelEdit,
)
else
const SizedBox(width: 8),
Expanded(
child: Text(
_editing
? (_editingSource == null ? '添加弹幕源' : '编辑弹幕源')
: '弹幕源',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: '关闭',
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
Widget _buildListView(ThemeData theme) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
Expanded(
child: sources.isEmpty
? Center(
child: Text(
'尚未配置弹幕源',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: sources.length,
onReorder: (oldIndex, newIndex) =>
unawaited(reorderSource(oldIndex, newIndex)),
itemBuilder: (context, index) {
final source = sources[index];
return ListTile(
key: ValueKey(source.id),
leading: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle, size: 18),
),
title: Text(
source.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: source.name.trim().isEmpty
? null
: Text(
source.url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FSwitch(
value: source.enabled,
onChange: (v) => unawaited(toggleSource(source, v)),
),
IconButton(
tooltip: '编辑',
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => _startEdit(source),
),
IconButton(
tooltip: '移除',
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () => unawaited(removeSource(source)),
),
],
),
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Row(
children: [
Expanded(
child: FButton(
onPress: () => _startEdit(null),
prefix: const Icon(Icons.add, size: 16),
child: const Text('添加'),
),
),
],
),
),
],
);
}
Widget _buildEditView() {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: '名称',
hintText: '例如:主源、备用源',
isDense: true,
),
),
const SizedBox(height: 16),
TextField(
controller: _urlCtrl,
autofocus: _editingSource == null,
decoration: const InputDecoration(
labelText: '服务地址',
hintText: 'http://localhost:8080',
isDense: true,
),
),
],
),
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Row(
children: [
Expanded(
child: FButton(
onPress: _cancelEdit,
variant: FButtonVariant.outline,
child: const Text('取消'),
),
),
const SizedBox(width: 12),
Expanded(
child: FButton(
onPress: () => unawaited(_saveEdit()),
child: const Text('保存'),
),
),
],
),
),
],
);
}
}