Files
video/app/lib/providers/feed_provider.dart
T
2026-10-25 20:30:33 +08:00

390 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/feed_progress_store.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', '热度'),
liked('liked', '口碑'),
viewed('viewed', '热播'),
// 搜索播放专用策略:数据由搜索页注入,不走网络 refresh;
// 顶部 Tab 不显示该项,仅作为独立播放器池存在
search('search', '搜索');
const FeedStrategy(this.apiValue, this.label);
final String apiValue;
final String label;
}
/// Feed 顶部当前选中的策略:HomeShell 切底部 Tab 回来时,
/// 只恢复该策略对应的播放器池(其余池保持挂起,防止幽灵声)
final activeFeedStrategyProvider =
StateProvider<FeedStrategy>((_) => FeedStrategy.recommend);
/// 首页底部 Tab 当前是否可见:App 回前台时仅在可见状态下恢复播放
/// (否则在"我的"等页面退后台再回来会误恢复声音)
final feedTabVisibleProvider = StateProvider<bool>((_) => true);
/// 游客今日免费额度已用尽(全局):所有 Feed 策略/搜索播放统一锁死播放,
/// 直到登录成功才解除——避免切换 Tab 或点「再逛逛」后继续免费观看
final guestLockedProvider = StateProvider<bool>((_) => false);
/// 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<VideoItem> 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<VideoItem>? 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<FeedState> {
FeedNotifier(this._ref, this.strategy) : super(const FeedState()) {
pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init();
// search 策略由外部 injectItems 灌入数据,不走网络 refresh
if (strategy != FeedStrategy.search) {
refresh();
}
}
final Ref _ref;
final FeedStrategy strategy;
late final FeedPlayerPool pool;
final _eventController = StreamController<FeedEvent>.broadcast();
/// UI 事件流(FeedScreen 监听后弹窗/Toast)
Stream<FeedEvent> get events => _eventController.stream;
WatchDedupStore get _dedup => WatchDedupStore.I;
/// 刷新(首屏 / 下拉 / 切策略)
Future<void> refresh() async {
// search 策略不支持下拉刷新(数据由搜索页注入)
if (strategy == FeedStrategy.search) return;
state = state.copyWith(isRefreshing: true, loadFailed: false);
try {
final page = await VideoService.fetchFeed(type: strategy.apiValue);
// 冷启动续播:按上次观看的 videoId 在新列表中定位(列表内容可能变化);
// 找不到时回退保存的下标(仅当仍在当前页范围内)
final saved = FeedProgressStore.I.read(strategy.name);
var index = 0;
if (saved != null && page.items.isNotEmpty) {
final hit = page.items.indexWhere((v) => v.videoId == saved.videoId);
if (hit >= 0) {
index = hit;
} else if (saved.index < page.items.length) {
index = saved.index;
}
pool.setResumePoint(saved.videoId, Duration(milliseconds: saved.positionMs));
}
state = FeedState(
items: page.items,
nextCursor: page.nextCursor,
currentIndex: index,
freeRemainingToday: state.freeRemainingToday,
);
if (page.items.isNotEmpty) {
_bindAndReport(index);
}
} catch (_) {
state = state.copyWith(isRefreshing: false, loadFailed: true);
return;
}
}
/// 加载更多(游标分页)
Future<void> loadMore() async {
// search 策略暂不支持自动加载更多(如需要可由搜索页同步注入新数据)
if (strategy == FeedStrategy.search) return;
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);
}
}
/// 外部数据注入(仅 search 策略用):把搜索结果整列表灌入并定位到点击的那一条。
/// 池子由 SearchFeedScreen 独占,注入后立即绑定播放。
Future<void> injectItems(List<VideoItem> items, int initialIndex) async {
if (strategy != FeedStrategy.search) return;
if (items.isEmpty) return;
final index = initialIndex.clamp(0, items.length - 1);
state = FeedState(items: items, currentIndex: index);
await _bindAndReport(index);
}
/// 从全屏播放器返回后恢复:stopAll 清空了槽位,重新绑定当前页并播放。
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
Future<void> rebindCurrent() async {
final index = state.currentIndex;
if (index < 0 || index >= state.items.length) return;
await pool.bindWindow(
index,
[for (final v in state.items) (videoId: v.videoId, url: resolvePlayUrl(v))],
);
}
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
void onPageChanged(int index) {
if (index < 0 || index >= state.items.length) return;
if (_ref.read(guestLockedProvider)) {
// 游客已锁:只记位置,不绑定播放
state = state.copyWith(currentIndex: index);
return;
}
final item = state.items[index];
// 同一视频重新绑定(冷启动跳页)时保留播放位置,否则新页从 0 开始
final sameVideo = pool.currentVideoId == item.videoId;
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
pool.pauseExcept(slotIndex: pool.slotOf(index));
state = state.copyWith(currentIndex: index);
// fire-and-forget:Future 内部会完整 await bindWindow 再 play,调用方不等
_bindAndReport(index);
// 持久化进度(杀进程重进时续播)
unawaited(FeedProgressStore.I.save(
strategy.name,
videoId: item.videoId,
index: index,
positionMs: sameVideo ? pool.currentPosition.inMilliseconds : 0,
));
// 接近尾部预取下一页
if (index >= state.items.length - 3) loadMore();
}
/// 持久化当前播放进度(App 退后台 / 切走 Tab / 定时兜底调用)
Future<void> persistProgress() async {
final index = state.currentIndex;
if (index < 0 || index >= state.items.length) return;
final item = state.items[index];
final positionMs =
pool.currentVideoId == item.videoId ? pool.currentPosition.inMilliseconds : 0;
await FeedProgressStore.I.save(
strategy.name,
videoId: item.videoId,
index: index,
positionMs: positionMs,
);
}
/// 绑定播放窗口 + 观看计费上报
Future<void> _bindAndReport(int index) async {
if (index < 0 || index >= state.items.length) return;
// 游客免费额度已用尽:锁死播放(不绑定、不播放),直到登录成功
if (_ref.read(guestLockedProvider)) 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) {
// 全局锁:此后任何策略/搜索播放都不再起播,点「再逛逛」也看不了
_ref.read(guestLockedProvider.notifier).state = true;
_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<void> _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<void> 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<void> 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<FeedNotifier, FeedState, FeedStrategy>(
(ref, strategy) => FeedNotifier(ref, strategy),
);