462 lines
13 KiB
Dart
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(),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|