This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
@@ -0,0 +1,230 @@
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),
),
],
),
),
);
}
}
+45 -5
View File
@@ -3,8 +3,11 @@ 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):
@@ -24,12 +27,22 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
@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();
@@ -283,7 +296,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
);
}
return VideoGridCard(item: state.items[index]);
return VideoGridCard(
item: state.items[index],
allItems: state.items,
initialIndex: index,
);
},
),
),
@@ -293,15 +310,37 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
/// 视频网格卡片:封面 + 标题 + 作者 + 播放量(搜索结果 / 标签视频页共用)
class VideoGridCard extends StatelessWidget {
const VideoGridCard({super.key, required this.item});
/// 点击行为:把当前列表注入到 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) {
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
onTap: () => context.push('/search/play', extra: item),
// 宿主页(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: [
@@ -315,6 +354,7 @@ class VideoGridCard extends StatelessWidget {
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
cacheManager: CoverCacheManager.I,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
+12 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/feed_provider.dart';
import '../../providers/search_provider.dart';
import '../../providers/tag_videos_provider.dart';
import 'search_screen.dart';
@@ -23,11 +24,17 @@ class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
@override
void initState() {
super.initState();
// 进入标签页即挂起首页 Feed(保存断点 + 停所有槽位)
WidgetsBinding.instance.addPostFrameCallback((_) {
final strategy = ref.read(activeFeedStrategyProvider);
ref.read(feedProvider(strategy).notifier).pool.suspend();
});
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
// 首页恢复播放由 FeedScreen.didPopNext 统一处理
_scrollController.dispose();
super.dispose();
}
@@ -129,7 +136,11 @@ class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
),
);
}
return VideoGridCard(item: state.items[index]);
return VideoGridCard(
item: state.items[index],
allItems: state.items,
initialIndex: index,
);
},
),
),