Initial commit
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user