Files
sm-emby-share/lib/features/player/widgets/danmaku_search_dialog.dart
T
2026-07-14 11:11:36 +08:00

462 lines
13 KiB
Dart

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<DanmakuMatchCandidate> 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<DanmakuSearchDialog> createState() =>
_DanmakuSearchDialogState();
}
class _DanmakuSearchDialogState extends ConsumerState<DanmakuSearchDialog> {
DanmakuSessionNotifier get _notifier =>
ref.read(danmakuSessionProvider.notifier);
final TextEditingController _queryCtrl = TextEditingController();
final FocusNode _focusNode = FocusNode();
final ScrollController _episodesScrollCtrl = ScrollController();
final Map<String, List<DanmakuBangumiEpisode>> _episodeCache = {};
static const double _kEpisodeItemExtent = 68.0;
_Phase _phase = _Phase.idle;
List<DanmakuMatchCandidate> _results = const [];
DanmakuMatchCandidate? _activeMatch;
List<DanmakuBangumiEpisode> _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<DanmakuBangumiEpisode> 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<void> _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<void> _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<void> _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<void>.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<void> _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<void>.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 <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
DismissIntent: CallbackAction<DismissIntent>(
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(),
),
),
),
],
),
),
),
);
}
}