Files
video/app/lib/screens/search/search_screen.dart
T
2026-10-25 11:46:44 +08:00

399 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/media/cover_cache.dart';
import '../../core/log/action_logger.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../providers/feed_provider.dart';
import '../../providers/search_provider.dart';
/// 搜索页(《01-开发文档》2.5.8):
/// 默认页:本地搜索历史(最近 10 条)+ 热搜榜 Top10
/// 结果页:热度/最新排序切换 + 双列封面网格,点击进单视频播放页
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
ActionLogger.I.log('route_enter', page: '/search');
// 打开搜索页挂起首页 Feed:保存当前播放断点 + 停所有槽位。
// 用 suspend 而非 stopAll——stopAll 会清掉断点导致返回时从头播,
// suspend 会把当前槽位置存到 _resumePosition,rebindCurrent 时经 mpv start 续播
WidgetsBinding.instance.addPostFrameCallback((_) {
final strategy = ref.read(activeFeedStrategyProvider);
ref.read(feedProvider(strategy).notifier).pool.suspend();
});
ref.read(searchProvider.notifier).loadHot();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
ActionLogger.I.log('route_exit', page: '/search');
// 首页恢复播放改由 FeedScreen 通过 RouteObserver.didPopNext 感知,这里不再处理
_inputController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(searchProvider.notifier).loadMore();
}
}
void _submit(String keyword) =>
ref.read(searchProvider.notifier).search(keyword);
@override
Widget build(BuildContext context) {
final state = ref.watch(searchProvider);
final notifier = ref.read(searchProvider.notifier);
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 搜索输入栏
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 20),
onPressed: () => context.pop(),
),
Expanded(
child: TextField(
controller: _inputController,
autofocus: true,
maxLength: 30,
textInputAction: TextInputAction.search,
onSubmitted: _submit,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
hintText: '搜索视频标题或标签',
counterText: '',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
),
TextButton(
onPressed: () => _submit(_inputController.text),
child: const Text(
'搜索',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
// 内容:默认页 / 结果页
Expanded(
child: state.searched
? _buildResults(state, notifier)
: _buildDefault(state, notifier),
),
],
),
),
);
}
/// 默认页:搜索历史 + 热搜榜
Widget _buildDefault(SearchState state, SearchNotifier notifier) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
if (state.history.isNotEmpty) ...[
Row(
children: [
const Expanded(
child: Text(
'搜索历史',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 20, color: AppColors.textSecondary),
onPressed: notifier.clearHistory,
),
],
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final word in state.history)
GestureDetector(
onTap: () {
_inputController.text = word;
_submit(word);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Text(
word,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
),
),
],
),
const SizedBox(height: 24),
],
const Text(
'热搜榜',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
if (state.hotWords.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'暂无热搜',
style: TextStyle(color: AppColors.textWeak),
),
),
)
else
for (var i = 0; i < state.hotWords.length; i++)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: SizedBox(
width: 24,
child: Text(
'${i + 1}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
// 前三名品牌红,其余灰色
color: i < 3 ? AppColors.primary : AppColors.textWeak,
),
),
),
title: Text(state.hotWords[i]),
onTap: () {
_inputController.text = state.hotWords[i];
_submit(state.hotWords[i]);
},
),
],
);
}
/// 结果页:排序切换 + 双列网格
Widget _buildResults(SearchState state, SearchNotifier notifier) {
return Column(
children: [
// 排序切换
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
for (final s in SearchSort.values) ...[
GestureDetector(
onTap: () => notifier.search(state.keyword, sort: s),
child: Text(
s.label,
style: TextStyle(
fontSize: 14,
fontWeight: s == state.sort
? FontWeight.w700
: FontWeight.w400,
color: s == state.sort
? Colors.white
: AppColors.textSecondary,
),
),
),
const SizedBox(width: 20),
],
const Spacer(),
if (state.total > 0)
Text(
'共 ${state.total} 条',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: state.isSearching
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: state.items.isEmpty
? const Center(
child: Text(
'未找到相关视频',
style: TextStyle(color: AppColors.textSecondary),
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount:
state.items.length + (state.isLoadingMore ? 2 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
);
}
return VideoGridCard(
item: state.items[index],
allItems: state.items,
initialIndex: index,
);
},
),
),
],
);
}
}
/// 视频网格卡片:封面 + 标题 + 作者 + 播放量(搜索结果 / 标签视频页共用)
/// 点击行为:把当前列表注入到 feedProvider(FeedStrategy.search),跳 /search/feed
/// 进入全屏 Feed 播放,支持上下滑切换上/下一个视频
class VideoGridCard extends ConsumerWidget {
const VideoGridCard({
super.key,
required this.item,
required this.allItems,
required this.initialIndex,
});
final VideoItem item;
/// 当前列表的所有视频(搜索结果/标签结果)
final List<VideoItem> allItems;
/// 当前点击的视频在列表中的下标
final int initialIndex;
@override
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
// 宿主页(SearchScreen / TagVideosScreen)已在进入时挂起首页 Feed,
// 这里只负责把列表注入 search 策略池并跳转;首页池恢复由宿主页 dispose 统一处理
onTap: () async {
final searchFeedNotifier =
ref.read(feedProvider(FeedStrategy.search).notifier);
await searchFeedNotifier.injectItems(allItems, initialIndex);
if (!context.mounted) return;
context.push('/search/feed');
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
color: AppColors.surface,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
)
: const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
),
),
),
const SizedBox(height: 4),
Text(
item.title.isNotEmpty ? item.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
Text(
'${item.author.nickname} · ${_formatCount(item.viewCount)}次播放',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 11,
),
),
],
),
);
}
static String _formatCount(int n) {
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
return n.toString();
}
}