import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/config/test_stream.dart'; import '../core/network/api_exception.dart'; import '../core/player/feed_player_pool.dart'; import '../core/storage/watch_dedup_store.dart'; import '../models/video_item.dart'; import '../services/video_service.dart'; import 'auth_provider.dart'; /// Feed 策略(对应《01-开发文档》2.5.6) enum FeedStrategy { recommend('recommend', '推荐'), latest('latest', '最新'), hot('hot', '热度'); const FeedStrategy(this.apiValue, this.label); final String apiValue; final String label; } /// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast) sealed class FeedEvent { const FeedEvent(); } /// 扣积分提示:顶部 Toast「本次消耗 N 积分」 class FeedToastEvent extends FeedEvent { const FeedToastEvent(this.message); final String message; } /// 游客超限:弹登录引导 class FeedGuestLimitEvent extends FeedEvent { const FeedGuestLimitEvent(); } /// 积分不足:弹「去充值 / 开通会员」 class FeedPointsInsufficientEvent extends FeedEvent { const FeedPointsInsufficientEvent(this.points); final int points; } /// Feed 状态 class FeedState { const FeedState({ this.items = const [], this.nextCursor, this.currentIndex = 0, this.isRefreshing = false, this.isLoadingMore = false, this.loadFailed = false, this.freeRemainingToday, }); final List items; final String? nextCursor; final int currentIndex; final bool isRefreshing; final bool isLoadingMore; /// 首屏加载失败(网络异常) final bool loadFailed; /// 今日免费剩余次数(首次 watch 响应后落地,null 时不显示提示条) final int? freeRemainingToday; bool get hasMore => nextCursor != null; FeedState copyWith({ List? items, String? Function()? nextCursor, int? currentIndex, bool? isRefreshing, bool? isLoadingMore, bool? loadFailed, int? Function()? freeRemainingToday, }) => FeedState( items: items ?? this.items, nextCursor: nextCursor != null ? nextCursor() : this.nextCursor, currentIndex: currentIndex ?? this.currentIndex, isRefreshing: isRefreshing ?? this.isRefreshing, isLoadingMore: isLoadingMore ?? this.isLoadingMore, loadFailed: loadFailed ?? this.loadFailed, freeRemainingToday: freeRemainingToday != null ? freeRemainingToday() : this.freeRemainingToday, ); } class FeedNotifier extends StateNotifier { FeedNotifier(this._ref, this.strategy) : super(const FeedState()) { pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init(); refresh(); } final Ref _ref; final FeedStrategy strategy; late final FeedPlayerPool pool; final _eventController = StreamController.broadcast(); /// UI 事件流(FeedScreen 监听后弹窗/Toast) Stream get events => _eventController.stream; WatchDedupStore get _dedup => WatchDedupStore.I; /// 刷新(首屏 / 下拉 / 切策略) Future refresh() async { state = state.copyWith(isRefreshing: true, loadFailed: false); try { final page = await VideoService.fetchFeed(type: strategy.apiValue); state = FeedState( items: page.items, nextCursor: page.nextCursor, freeRemainingToday: state.freeRemainingToday, ); if (page.items.isNotEmpty) { _bindAndReport(0); } } catch (_) { state = state.copyWith(isRefreshing: false, loadFailed: true); return; } } /// 加载更多(游标分页) Future loadMore() async { if (!state.hasMore || state.isLoadingMore) return; state = state.copyWith(isLoadingMore: true); try { final page = await VideoService.fetchFeed( type: strategy.apiValue, cursor: state.nextCursor, ); state = state.copyWith( items: [...state.items, ...page.items], nextCursor: () => page.nextCursor, isLoadingMore: false, ); } catch (_) { state = state.copyWith(isLoadingMore: false); } } /// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3) void onPageChanged(int index) { // 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声 pool.pauseExcept(slotIndex: pool.slotOf(index)); state = state.copyWith(currentIndex: index); // fire-and-forget:Future 内部会完整 await bindWindow 再 play,调用方不等 _bindAndReport(index); // 接近尾部预取下一页 if (index >= state.items.length - 3) loadMore(); } /// 绑定播放窗口 + 观看计费上报 Future _bindAndReport(int index) async { if (index < 0 || index >= state.items.length) return; final items = state.items; await pool.bindWindow( index, [for (final v in items) (videoId: v.videoId, url: resolvePlayUrl(v))], ); final item = items[index]; // 同日同视频只上报一次(先标记再请求,失败不回滚,防重复扣费) if (_dedup.hasWatched(item.videoId)) return; await _dedup.markWatched(item.videoId); try { final result = await VideoService.reportWatch(item.videoId); if (result.isPointsCost && result.costPoints > 0) { _eventController.add(FeedToastEvent('本次消耗 ${result.costPoints} 积分')); } state = state.copyWith( freeRemainingToday: () => result.freeRemainingToday, ); } on ApiException catch (e) { pool.pauseCurrent(); if (e.isGuestLimit) { _eventController.add(const FeedGuestLimitEvent()); } else if (e.isPointsInsufficient) { final points = (e.data?['points'] as num?)?.toInt() ?? 0; _eventController.add(FeedPointsInsufficientEvent(points)); } // 其他错误静默,不影响播放 } catch (_) { // 网络异常静默,播放不中断 } } /// 完整观看上报(pool ≥95%/播完回调):服务端同日去重,本地再省一次请求 Future _onWatchComplete(int videoId, int watchDuration) async { if (watchDuration <= 0 || _dedup.hasCompleted(videoId)) return; await _dedup.markCompleted(videoId); try { await VideoService.reportComplete(videoId, watchDuration); } catch (_) { // credited=false 与网络异常均无需 UI 处理 } } /// 点赞(乐观更新,失败回滚);双击已点赞时仅播动画不调用 Future toggleLike(int videoId) async { if (!_checkLoggedIn()) return; final index = _indexOf(videoId); if (index < 0) return; final old = state.items[index]; _replaceItem( index, old.copyWith( isLiked: !old.isLiked, likeCount: old.isLiked ? old.likeCount - 1 : old.likeCount + 1, ), ); try { await VideoService.toggleLike(videoId); } catch (_) { _replaceItem(index, old); // 回滚 _eventController.add(const FeedToastEvent('操作失败,请重试')); } } /// 评论数增减同步(评论弹层发表/删除成功后回调,本地计数 ±1) void adjustCommentCount(int videoId, int delta) { final index = _indexOf(videoId); if (index < 0) return; final old = state.items[index]; _replaceItem( index, old.copyWith(commentCount: (old.commentCount + delta).clamp(0, 1 << 31)), ); } /// 收藏(乐观更新,失败回滚) Future toggleFavorite(int videoId) async { if (!_checkLoggedIn()) return; final index = _indexOf(videoId); if (index < 0) return; final old = state.items[index]; _replaceItem( index, old.copyWith( isFavorited: !old.isFavorited, favoriteCount: old.isFavorited ? old.favoriteCount - 1 : old.favoriteCount + 1, ), ); try { await VideoService.toggleFavorite(videoId); } catch (_) { _replaceItem(index, old); _eventController.add(const FeedToastEvent('操作失败,请重试')); } } bool _checkLoggedIn() { final loggedIn = _ref.read(authProvider).isLoggedIn; if (!loggedIn) { // 未登录:发事件由 UI 跳登录页(复用 Toast 事件表达意图太重,直接在这里不管 UI, // 由 interaction_bar 的点击回调先判登录态。此处双保险静默即可) return false; } return true; } int _indexOf(int videoId) => state.items.indexWhere((v) => v.videoId == videoId); void _replaceItem(int index, VideoItem item) { final items = [...state.items]; items[index] = item; state = state.copyWith(items: items); } @override void dispose() { _eventController.close(); pool.dispose(); super.dispose(); } } /// Feed Provider:按策略各持一份(切 Tab 互不污染,切回保持滚动位置) final feedProvider = StateNotifierProvider.family( (ref, strategy) => FeedNotifier(ref, strategy), );