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

593 lines
18 KiB
Dart

part of 'danmaku_search_dialog.dart';
extension _DanmakuSearchDialogView on _DanmakuSearchDialogState {
Widget _slideFade(Widget child, Animation<double> animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: _transitionOffset,
end: Offset.zero,
).animate(curved),
child: child,
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 10),
child: Row(
children: [
const Icon(Icons.search, color: Colors.white, size: 20),
const SizedBox(width: 8),
const Text(
'搜索弹幕源',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
onPressed: _dismiss,
icon: const Icon(Icons.close, color: Colors.white70),
tooltip: '关闭',
),
],
),
);
}
Widget _buildSearchField() {
return SizedBox(
height: 44,
child: TextField(
controller: _queryCtrl,
focusNode: _focusNode,
autofocus: true,
enabled: !_busy,
style: const TextStyle(color: Colors.white, fontSize: 13),
cursorColor: Colors.lightBlueAccent,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
filled: true,
fillColor: Colors.white.withValues(alpha: 0.08),
hintText: '输入番剧名后按 Enter 搜索',
hintStyle: const TextStyle(color: Colors.white38, fontSize: 13),
prefixIcon: const Icon(Icons.search, color: Colors.white54, size: 18),
suffixIcon: IconButton(
onPressed: _busy ? null : _submit,
icon: const Icon(Icons.arrow_forward, size: 18),
color: Colors.white70,
tooltip: '搜索',
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 0,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kDivider),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kDivider),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kAccent),
),
),
onSubmitted: (_) => _submit(),
),
);
}
Widget _buildBody() {
switch (_phase) {
case _Phase.idle:
return _buildStateBody(
key: const ValueKey('idle'),
icon: Icons.search,
title: '输入番剧名,按 Enter 搜索',
subtitle: '可手动选择具体集数',
showMessage: true,
);
case _Phase.searching:
return _buildLoadingBody(
key: const ValueKey('searching'),
text: '正在搜索…',
);
case _Phase.results:
return _buildResultsBody(
key: const ValueKey('results'),
disabled: false,
);
case _Phase.empty:
return _buildStateBody(
key: const ValueKey('empty'),
icon: Icons.search_off,
title: '未找到匹配的弹幕源',
subtitle: '换个剧名试试',
);
case _Phase.error:
return _buildStateBody(
key: const ValueKey('error'),
icon: Icons.error_outline,
title: _messageText ?? '搜索失败',
subtitle: '请检查弹幕服务地址',
);
case _Phase.episodesLoading:
return _buildEpisodesLoadingBody();
case _Phase.episodes:
return _buildEpisodesBody(
key: const ValueKey('episodes'),
disabled: false,
);
case _Phase.applying:
return _buildApplyingBody();
}
}
Widget _buildStateBody({
required Key key,
required IconData icon,
required String title,
String? subtitle,
bool showMessage = false,
}) {
return Center(
key: key,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white24, size: 32),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
if (showMessage && _messageText != null) ...[
const SizedBox(height: 16),
_buildMessageBanner(),
],
],
),
),
);
}
Widget _buildLoadingBody({required Key key, required String text}) {
return Center(
key: key,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppLoadingRing(size: 22, color: Colors.white),
const SizedBox(height: 12),
Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
);
}
Widget _buildResultsBody({required Key key, required bool disabled}) {
return Column(
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'搜索结果 ${_results.length}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
if (_messageText != null) ...[
const SizedBox(height: 12),
_buildMessageBanner(),
],
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: _results.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) => _buildResultTile(_results[i], disabled),
),
),
],
);
}
Widget _buildEpisodesLoadingBody() {
return Column(
key: const ValueKey('episodes-loading'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEpisodesHeader(disabled: false),
const SizedBox(height: 16),
const Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppLoadingRing(size: 22, color: Colors.white),
SizedBox(height: 12),
Text(
'正在加载分集…',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
),
],
);
}
Widget _buildEpisodesBody({
required Key key,
required bool disabled,
bool useScrollController = true,
}) {
return Column(
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEpisodesHeader(disabled: disabled),
if (_messageText != null) ...[
const SizedBox(height: 12),
_buildMessageBanner(),
],
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
controller: useScrollController ? _episodesScrollCtrl : null,
padding: EdgeInsets.zero,
itemCount: _episodes.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) => _buildEpisodeTile(_episodes[i], i, disabled),
),
),
],
);
}
Widget _buildApplyingBody() {
final base = _activeMatch != null && _episodes.isNotEmpty
? _buildEpisodesBody(
key: const ValueKey('applying-episodes'),
disabled: true,
useScrollController: false,
)
: _buildResultsBody(
key: const ValueKey('applying-results'),
disabled: true,
);
return Stack(
key: const ValueKey('applying'),
fit: StackFit.expand,
children: [
base,
Positioned.fill(
child: ColoredBox(color: Colors.black.withValues(alpha: 0.18)),
),
const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppLoadingRing(size: 22, color: Colors.white),
SizedBox(height: 12),
Text(
'正在加载弹幕…',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
],
);
}
Widget _buildEpisodesHeader({required bool disabled}) {
return Row(
children: [
IconButton(
onPressed: disabled ? null : _backToResults,
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_back, color: Colors.white70),
tooltip: '返回',
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_activeMatch?.animeTitle ?? '选择分集',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
const Text(
'选择要加载的集数',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
],
);
}
Widget _buildMessageBanner() {
final color = _messageIsError ? Colors.redAccent : _kAccent;
final icon = _messageIsError ? Icons.error_outline : Icons.info_outline;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
_messageText ?? '',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
),
);
}
Widget _buildResultTile(DanmakuMatchCandidate match, bool disabled) {
final highlighted =
_flashEpisodeId == match.episodeId &&
_flashSource?.id == match.source.id;
final canDrill = match.animeId > 0;
final subtitle = match.episodeTitle.isNotEmpty
? match.episodeTitle
: canDrill
? '进入分集列表后选择具体集数'
: '直接加载当前搜索结果';
final showTargetHint = widget.targetEpisode != null && canDrill;
return InkWell(
onTap: disabled
? null
: () {
if (canDrill) {
_openAnime(match);
} else {
_applyDirect(match);
}
},
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: highlighted
? Colors.white12
: Colors.white.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: highlighted
? Colors.lightBlueAccent.withValues(alpha: 0.7)
: Colors.white.withValues(alpha: 0.08),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
match.animeTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
height: 1.35,
),
),
const SizedBox(height: 4),
Text(
match.source.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
if (showTargetHint) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.adjust,
size: 12,
color: Colors.white38,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'目标:${formatEpisodeNumber(widget.targetEpisode)}(点击选择)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
),
),
],
),
],
],
),
),
const SizedBox(width: 12),
_buildResultAffordance(canDrill: canDrill),
],
),
),
);
}
Widget _buildResultAffordance({required bool canDrill}) {
if (canDrill) {
return const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('选择集数', style: TextStyle(color: Colors.white54, fontSize: 11)),
SizedBox(width: 2),
Icon(Icons.chevron_right, size: 16, color: Colors.white54),
],
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _kAccent.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _kAccent.withValues(alpha: 0.4)),
),
child: const Text(
'直接加载',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildEpisodeTile(
DanmakuBangumiEpisode episode,
int index,
bool disabled,
) {
final highlighted = _flashEpisodeId == episode.episodeId;
final epNum = episode.episodeNumber.trim();
final episodeLabel = epNum.isEmpty
? formatEpisodeNumber(index + 1)!
: '$epNum集';
return InkWell(
onTap: disabled || _activeMatch == null
? null
: () => _applyEpisode(_activeMatch!, episode),
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: highlighted
? Colors.white12
: Colors.white.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: highlighted
? Colors.lightBlueAccent.withValues(alpha: 0.7)
: Colors.white.withValues(alpha: 0.08),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episodeLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
if (episode.episodeTitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episode.episodeTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
height: 1.35,
),
),
],
],
),
),
const SizedBox(width: 12),
const Text(
'点击加载',
style: TextStyle(color: Colors.white54, fontSize: 11),
),
],
),
),
);
}
}