import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/log/action_logger.dart'; import '../../core/theme/app_theme.dart'; import '../../models/video_item.dart'; import '../../providers/auth_provider.dart'; import '../../providers/feed_provider.dart'; import '../../providers/search_provider.dart'; import '../../router.dart'; import '../feed/widgets/comment_sheet.dart'; import '../feed/widgets/feed_overlays.dart'; import '../feed/widgets/feed_video_page.dart'; /// 搜索结果播放页:复用 Feed 播放器池的「第 6 个策略」, /// 支持上下滑切上/下一个视频、双击点赞、长按倍速/分享、评论等完整体验。 /// 数据由搜索页 injectItems 注入,不走网络刷新;触底加载更多由搜索 provider 完成后再注入。 class SearchFeedScreen extends ConsumerStatefulWidget { const SearchFeedScreen({super.key}); @override ConsumerState createState() => _SearchFeedScreenState(); } class _SearchFeedScreenState extends ConsumerState with WidgetsBindingObserver, RouteAware { static const _strategy = FeedStrategy.search; final PageController _pageController = PageController(); StreamSubscription? _eventSub; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); ActionLogger.I.log('route_enter', page: '/search/feed'); _eventSub = ref .read(feedProvider(_strategy).notifier) .events .listen(_handleEvent); // 注入完成后跳到目标 index(数据由搜索页在 push 前 injectItems 灌入) WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final idx = ref.read(feedProvider(_strategy)).currentIndex; if (idx > 0 && _pageController.hasClients) { _pageController.jumpToPage(idx); } }); } @override void didChangeDependencies() { super.didChangeDependencies(); final route = ModalRoute.of(context); if (route is PageRoute) routeObserver.subscribe(this, route); } /// 本页之上又 push 了新路由(评论/分享/登录等):停掉搜索池,避免返回路径复杂时残留声音 @override void didPushNext() { ref.read(feedProvider(_strategy).notifier).pool.stopAll(); } /// 上层路由 pop 回本页:恢复播放 @override void didPopNext() { if (!mounted) return; // 游客额度已用尽:保持锁定,不恢复播放 if (ref.read(guestLockedProvider)) return; final notifier = ref.read(feedProvider(_strategy).notifier); unawaited(() async { await notifier.pool.resume(); await notifier.rebindCurrent(); }()); } @override void dispose() { ActionLogger.I.log('route_exit', page: '/search/feed'); WidgetsBinding.instance.removeObserver(this); routeObserver.unsubscribe(this); _eventSub?.cancel(); // 离开搜索播放:直接停掉搜索策略的池(断音 + 断缓存下载)。 ref.read(feedProvider(_strategy).notifier).pool.stopAll(); _pageController.dispose(); super.dispose(); } /// App 退后台挂起、回前台从断点续播 @override void didChangeAppLifecycleState(AppLifecycleState state) { final notifier = ref.read(feedProvider(_strategy).notifier); if (state == AppLifecycleState.paused) { notifier.pool.suspend(); } else if (state == AppLifecycleState.inactive) { notifier.pool.pauseCurrent(); } else if (state == AppLifecycleState.resumed) { // 本页不可见时(压在登录页/评论页下)不恢复播放;游客锁定时也不恢复 if ((ModalRoute.of(context)?.isCurrent ?? false) && !ref.read(guestLockedProvider)) { unawaited(() async { await notifier.pool.resume(); await notifier.rebindCurrent(); }()); } } } void _handleEvent(FeedEvent event) { if (!mounted) return; switch (event) { case FeedToastEvent(:final message): showTopToast(context, message); case FeedGuestLimitEvent(): showGuestLimitDialog(context); case FeedPointsInsufficientEvent(:final points): showPointsInsufficientDialog(context, points); } } /// 打开评论弹层:打开期间暂停当前播放,关闭后恢复 Future _openComments(VideoItem item) async { final notifier = ref.read(feedProvider(_strategy).notifier); notifier.pool.pauseCurrent(); await showCommentSheet( context, videoId: item.videoId, onCountChanged: (delta) => notifier.adjustCommentCount(item.videoId, delta), ); if (mounted) notifier.pool.playCurrent(); } @override Widget build(BuildContext context) { final state = ref.watch(feedProvider(_strategy)); final notifier = ref.read(feedProvider(_strategy).notifier); final keyword = ref.watch(searchProvider).keyword; final guestLocked = ref.watch(guestLockedProvider); // 登录成功后解除游客锁并恢复当前视频 ref.listen(authProvider, (previous, next) { if (next.isLoggedIn && ref.read(guestLockedProvider)) { ref.read(guestLockedProvider.notifier).state = false; unawaited(ref.read(feedProvider(_strategy).notifier).rebindCurrent()); } }); return PopScope( // 系统返回键/手势也要先停搜索池再放行 pop onPopInvokedWithResult: (didPop, _) { if (didPop) { ref.read(feedProvider(_strategy).notifier).pool.stopAll(); } }, child: Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // 内容区 if (state.items.isEmpty) const Center( child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)), ) else NotificationListener( onNotification: (n) { // 手指开始拖动即静音,避免划动过程中上一个视频仍在出声 if (n is ScrollStartNotification && n.dragDetails != null) { notifier.pool.pauseCurrent(); } return false; }, child: PageView.builder( controller: _pageController, scrollDirection: Axis.vertical, itemCount: state.items.length, onPageChanged: notifier.onPageChanged, itemBuilder: (context, index) { final item = state.items[index]; return FeedVideoPage( key: ValueKey(item.videoId), item: item, slotIndex: notifier.pool.slotOf(index), pool: notifier.pool, isCurrent: index == state.currentIndex, onLike: notifier.toggleLike, onFavorite: notifier.toggleFavorite, onComment: _openComments, onReturnFromPlayer: notifier.rebindCurrent, ); }, ), ), // 顶部:返回按钮 + 搜索关键词 SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back_ios, size: 20, color: Colors.white), onPressed: () { // 先停搜索池再 pop,避免依赖 dispose 时机导致声音残留 ref.read(feedProvider(_strategy).notifier).pool.stopAll(); context.pop(); }, ), if (keyword.isNotEmpty) Expanded( child: Text( keyword, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, color: Colors.white, shadows: [Shadow(color: Colors.black54, blurRadius: 4)], ), ), ), ], ), ), ), // 底部:免费剩余次数提示条(≤3 时显示) Positioned( left: 0, right: 0, bottom: 0, child: FreeQuotaBanner(remaining: state.freeRemainingToday), ), // 游客免费额度已用尽:全屏锁(必须登录,点「再逛逛」也无法继续观看) if (guestLocked) const Positioned.fill(child: GuestLockOverlay()), ], ), ), ); } }