247 lines
8.8 KiB
Dart
247 lines
8.8 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/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<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;
|
|
// 游客额度已用尽:保持锁定,不恢复播放
|
|
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<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;
|
|
final guestLocked = ref.watch(guestLockedProvider);
|
|
|
|
// 登录成功后解除游客锁并恢复当前视频
|
|
ref.listen<AuthState>(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<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),
|
|
),
|
|
|
|
// 游客免费额度已用尽:全屏锁(必须登录,点「再逛逛」也无法继续观看)
|
|
if (guestLocked) const Positioned.fill(child: GuestLockOverlay()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|