add
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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);
|
||||
_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;
|
||||
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),
|
||||
);
|
||||
Reference in New Issue
Block a user