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 createState() => _SearchScreenState(); } class _SearchScreenState extends ConsumerState { 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 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(); } }