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

231 lines
8.0 KiB
Dart

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/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<SearchFeedScreen> createState() => _SearchFeedScreenState();
}
class _SearchFeedScreenState extends ConsumerState<SearchFeedScreen>
with WidgetsBindingObserver, RouteAware {
static const _strategy = FeedStrategy.search;
final PageController _pageController = PageController();
StreamSubscription<FeedEvent>? _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;
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) {
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<void> _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;
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<ScrollNotification>(
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),
),
],
),
),
);
}
}