import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import '../../../core/contracts/danmaku.dart'; import '../../../core/media/danmaku_episode_parser.dart'; import '../../../shared/utils/format_utils.dart'; import '../../../shared/widgets/app_loading_ring.dart'; import '../services/danmaku/danmaku_session_notifier.dart'; part 'danmaku_search_dialog_view.dart'; const _kBg = Color(0xE8181818); const _kDivider = Color(0x33FFFFFF); const _kAccent = Color(0xFF4FA8FF); enum _Phase { idle, searching, results, empty, error, episodesLoading, episodes, applying, } class DanmakuSearchDialog extends ConsumerStatefulWidget { final String currentItemId; final bool Function() isItemStillCurrent; final String? initialQuery; final List initialResults; final int initialSelectedIndex; final int? targetEpisode; const DanmakuSearchDialog({ super.key, required this.currentItemId, required this.isItemStillCurrent, this.initialQuery, this.initialResults = const [], this.initialSelectedIndex = -1, this.targetEpisode, }); @override ConsumerState createState() => _DanmakuSearchDialogState(); } class _DanmakuSearchDialogState extends ConsumerState { DanmakuSessionNotifier get _notifier => ref.read(danmakuSessionProvider.notifier); final TextEditingController _queryCtrl = TextEditingController(); final FocusNode _focusNode = FocusNode(); final ScrollController _episodesScrollCtrl = ScrollController(); final Map> _episodeCache = {}; static const double _kEpisodeItemExtent = 68.0; _Phase _phase = _Phase.idle; List _results = const []; DanmakuMatchCandidate? _activeMatch; List _episodes = const []; String? _messageText; bool _messageIsError = false; int? _flashEpisodeId; DanmakuSource? _flashSource; int _searchSeq = 0; int _episodeSeq = 0; int _applySeq = 0; Offset _transitionOffset = Offset.zero; bool get _busy => _phase == _Phase.searching || _phase == _Phase.episodesLoading || _phase == _Phase.applying; String _episodeCacheKey(DanmakuMatchCandidate match) => '${match.source.id}:${match.animeId}'; @override void initState() { super.initState(); final q = widget.initialQuery; if (q != null && q.isNotEmpty) { _queryCtrl.text = q; _queryCtrl.selection = TextSelection.collapsed(offset: q.length); } if (widget.initialResults.isNotEmpty) { _results = widget.initialResults; _phase = _Phase.results; final idx = widget.initialSelectedIndex; if (idx >= 0 && idx < widget.initialResults.length) { _flashEpisodeId = widget.initialResults[idx].episodeId; _flashSource = widget.initialResults[idx].source; } } } @override void dispose() { _queryCtrl.dispose(); _focusNode.dispose(); _episodesScrollCtrl.dispose(); super.dispose(); } int? _findTargetEpisodeIndex(List episodes) { final target = widget.targetEpisode; if (target == null) return null; for (var i = 0; i < episodes.length; i++) { final n = extractEpisodeNumberFromTitle(episodes[i].episodeTitle); if (n == target) return i; } return null; } void _scheduleEpisodeScroll(int targetIdx) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (!_episodesScrollCtrl.hasClients) return; final position = _episodesScrollCtrl.position; final viewport = position.viewportDimension; final desired = targetIdx * _kEpisodeItemExtent - (viewport - _kEpisodeItemExtent) / 2; final clamped = desired.clamp(0.0, position.maxScrollExtent); _episodesScrollCtrl.jumpTo(clamped); }); } void _dismiss() { Navigator.of(context, rootNavigator: true).maybePop(); } bool _ensureItemStillCurrent() { if (widget.isItemStillCurrent()) return true; _dismiss(); return false; } Future _submit() async { if (!_ensureItemStillCurrent()) return; final query = _queryCtrl.text.trim(); if (query.isEmpty) { setState(() { _phase = _Phase.idle; _messageText = '请输入番剧名'; _messageIsError = true; _flashEpisodeId = null; }); return; } final seq = ++_searchSeq; _episodeSeq++; _applySeq++; _transitionOffset = Offset.zero; setState(() { _phase = _Phase.searching; _activeMatch = null; _episodes = const []; _messageText = null; _messageIsError = false; _flashEpisodeId = null; }); try { final matches = await _notifier.manualSearch(query); if (!mounted || seq != _searchSeq) return; if (matches == null) { _dismiss(); return; } setState(() { _results = matches; _phase = matches.isEmpty ? _Phase.empty : _Phase.results; _messageText = null; _messageIsError = false; }); } catch (_) { if (!mounted || seq != _searchSeq) return; setState(() { _phase = _Phase.error; _messageText = '搜索失败,请检查弹幕服务地址'; _messageIsError = true; }); } } Future _openAnime(DanmakuMatchCandidate match) async { if (_busy) return; if (!_ensureItemStillCurrent()) return; if (match.animeId <= 0) { await _applyDirect(match); return; } final cacheKey = _episodeCacheKey(match); final cached = _episodeCache[cacheKey]; if (cached != null) { if (cached.isEmpty) { _transitionOffset = const Offset(-0.08, 0); setState(() { _phase = _Phase.results; _activeMatch = null; _episodes = const []; _messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"'; _messageIsError = false; _flashEpisodeId = null; }); return; } if (cached.length == 1) { _activeMatch = match; _episodes = cached; await _applyEpisode(match, cached.single); return; } _transitionOffset = const Offset(0.08, 0); final targetIdx = _findTargetEpisodeIndex(cached); setState(() { _activeMatch = match; _episodes = cached; _phase = _Phase.episodes; _messageText = null; _messageIsError = false; _flashEpisodeId = targetIdx != null ? cached[targetIdx].episodeId : null; }); if (targetIdx != null) _scheduleEpisodeScroll(targetIdx); return; } final seq = ++_episodeSeq; _applySeq++; _transitionOffset = const Offset(0.08, 0); setState(() { _activeMatch = match; _episodes = const []; _phase = _Phase.episodesLoading; _messageText = null; _messageIsError = false; _flashEpisodeId = null; }); try { final episodes = await _notifier.listEpisodesForAnime( match.source, match.animeId, ); if (!mounted || seq != _episodeSeq) return; if (episodes == null) { _dismiss(); return; } _episodeCache[cacheKey] = episodes; if (episodes.isEmpty) { _transitionOffset = const Offset(-0.08, 0); setState(() { _phase = _Phase.results; _activeMatch = null; _episodes = const []; _messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"'; _messageIsError = false; }); return; } if (episodes.length == 1) { _activeMatch = match; _episodes = episodes; await _applyEpisode(match, episodes.single); return; } _transitionOffset = const Offset(0.08, 0); final targetIdx = _findTargetEpisodeIndex(episodes); setState(() { _episodes = episodes; _phase = _Phase.episodes; _messageText = null; _messageIsError = false; _flashEpisodeId = targetIdx != null ? episodes[targetIdx].episodeId : null; }); if (targetIdx != null) _scheduleEpisodeScroll(targetIdx); } catch (_) { if (!mounted || seq != _episodeSeq) return; _transitionOffset = const Offset(-0.08, 0); setState(() { _phase = _Phase.results; _activeMatch = null; _episodes = const []; _messageText = '加载分集失败,请重试'; _messageIsError = true; _flashEpisodeId = null; }); } } void _backToResults() { _episodeSeq++; _applySeq++; _transitionOffset = const Offset(-0.08, 0); setState(() { _phase = _Phase.results; _activeMatch = null; _episodes = const []; _flashEpisodeId = null; }); } Future _applyEpisode( DanmakuMatchCandidate match, DanmakuBangumiEpisode episode, ) async { if (!_ensureItemStillCurrent()) return; final seq = ++_applySeq; _searchSeq++; _episodeSeq++; _transitionOffset = Offset.zero; setState(() { _flashEpisodeId = episode.episodeId; _phase = _Phase.applying; _messageText = null; _messageIsError = false; }); await Future.delayed(const Duration(milliseconds: 100)); if (!mounted || seq != _applySeq) return; if (!_ensureItemStillCurrent()) return; try { await _notifier.applyManualSelection( source: match.source, episodeId: episode.episodeId, animeId: match.animeId, animeTitle: match.animeTitle, episodeTitle: episode.episodeTitle, ); if (!mounted || seq != _applySeq) return; Navigator.of(context, rootNavigator: true).pop(); } catch (_) { if (!mounted || seq != _applySeq) return; setState(() { _phase = _episodes.isEmpty ? _Phase.results : _Phase.episodes; _messageText = '加载弹幕失败,请重试'; _messageIsError = true; _flashEpisodeId = null; }); } } Future _applyDirect(DanmakuMatchCandidate match) async { if (!_ensureItemStillCurrent()) return; final seq = ++_applySeq; _searchSeq++; _episodeSeq++; _transitionOffset = Offset.zero; setState(() { _flashEpisodeId = match.episodeId; _flashSource = match.source; _phase = _Phase.applying; _messageText = null; _messageIsError = false; }); await Future.delayed(const Duration(milliseconds: 100)); if (!mounted || seq != _applySeq) return; if (!_ensureItemStillCurrent()) return; try { await _notifier.applyManualSelection( source: match.source, episodeId: match.episodeId, animeId: match.animeId, animeTitle: match.animeTitle, episodeTitle: match.episodeTitle, ); if (!mounted || seq != _applySeq) return; Navigator.of(context, rootNavigator: true).pop(); } catch (_) { if (!mounted || seq != _applySeq) return; setState(() { _phase = _Phase.results; _messageText = '加载弹幕失败,请重试'; _messageIsError = true; _flashEpisodeId = null; _flashSource = null; }); } } @override Widget build(BuildContext context) { return Shortcuts( shortcuts: const { SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), }, child: Actions( actions: >{ DismissIntent: CallbackAction( onInvoke: (_) { _dismiss(); return null; }, ), }, child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: _kBg, borderRadius: BorderRadius.circular(12), border: Border.all(color: _kDivider), boxShadow: const [ BoxShadow( color: Colors.black54, blurRadius: 20, offset: Offset(0, 6), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildHeader(), const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: _buildSearchField(), ), const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), transitionBuilder: _slideFade, child: _buildBody(), ), ), ), ], ), ), ), ); } }