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 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 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( context: context, side: FLayout.rtl, mainAxisMaxRatio: 0.42, draggable: false, builder: (_) => AppThemeScope( child: _DanmakuSourcesDesktopSheet(initialSources: sources), ), ); } } mixin _DanmakuSourcesLogic on ConsumerState { late List sources; void initSources(List initial) { sources = List.of(initial); } Future persist(List next) async { final normalized = await ref .read(danmakuSettingsProvider.notifier) .setSources(next); if (!mounted) return; setState(() => sources = normalized); } Future showEditDialog(DanmakuSource? source) async { final nameCtrl = TextEditingController(text: source?.name ?? ''); final urlCtrl = TextEditingController(text: source?.url ?? ''); try { final saved = await showFDialog( 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 removeSource(DanmakuSource source) async { await persist(sources.where((s) => s.id != source.id).toList()); } Future toggleSource(DanmakuSource source, bool enabled) async { await persist( sources .map((s) => s.id == source.id ? s.copyWith(enabled: enabled) : s) .toList(), ); } Future 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 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 persist(List 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 _slideAnim; bool _expanded = false; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 250), ); _slideAnim = Tween( 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 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 _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('保存'), ), ), ], ), ), ], ); } }