Files
video/app/lib/providers/feed_provider.dart
T
2026-10-16 13:51:00 +08:00

292 lines
8.8 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/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<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();
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 {
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<void> 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) {
state = state.copyWith(currentIndex: index);
// fire-and-forget:Future 内部会完整 await bindWindow 再 play,调用方不等
_bindAndReport(index);
// 接近尾部预取下一页
if (index >= state.items.length - 3) loadMore();
}
/// 绑定播放窗口 + 观看计费上报
Future<void> _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<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),
);