6.4 KiB
6.4 KiB
播放链路(Feed 播放页)实施计划
Context
三端骨架已完成(server 32 后台接口 + 55 个 App 接口、admin Vue3 已验证、app Flutter 骨架 analyze 0 issue)。下一步是 App 的核心价值链路:首页 Feed 沉浸式播放 + 观看计费。
关键前提(已与用户确认):
- 服务端
/hls未就绪(无转码产物、未挂载静态路由),本次 只做 App 端,播放器验证用--dart-define=TEST_VIDEO_URL注入公网测试 m3u8;真实 /hls 留到上传/转码迭代。 - 本机无 Xcode/Android SDK,验证以
flutter analyze+flutter test为准。
契约依据:docs/03 §5.1-5.4(feed/watch/complete/like/favorite)、docs/01 §5.2.1/5.3(布局与交互规范)。
架构决策
- 播放器 3 槽位池化(
FeedPlayerPool):media_kit 每个 Player 是一个 mpv 实例,PageView 无限列表每页持有会 OOM。窗口[i-1, i, i+1]映射到index % 3槽位:当前 play、相邻 open 预缓冲、出窗 stop。 - watch 去重持久化(SharedPreferences):游客免费额度按设备计费,杀进程重进重复上报会白扣游客次数。key 按日分组
watch_yyyyMMdd,隔日惰性清理;complete 用complete_前缀同理。 - 状态两层:
feedProvider(family 按策略 recommend/latest/hot)管列表/游标/当前索引/freeRemainingToday;FeedPlayerPool管播放器生命周期。计费触发收在 Notifier.onShow,UI 不直接调 service。 - router 放开 /home 游客访问:游客可进 Feed,超限(2002)才弹登录引导。
- 骨架小改:
ApiException增加data字段(2001 需读 points);reportWatch返回强类型WatchResult;VideoItem 补copyWith(乐观更新)。
文件清单
新增(11 个)
| 文件 | 职责 |
|---|---|
app/lib/models/watch_result.dart |
watch 响应模型(costType/costPoints/remainingPoints/freeRemainingToday) |
app/lib/core/storage/watch_dedup_store.dart |
video_id+日期 去重集合(watch/complete 两组),SharedPreferences 持久化,隔日失效 |
app/lib/core/player/feed_player_pool.dart |
3 槽位 Player 池:bindWindow/播放/暂停/预缓冲/释放;进度监听聚合(≥95% 或 completed 回调一次);构造接受 Player 工厂便于测试注入 fake |
app/lib/providers/feed_provider.dart |
FeedState + FeedNotifier:refresh/loadMore/onPageChanged(计费触发)/toggleLike/toggleFavorite |
app/lib/core/config/test_stream.dart |
dev 钩子 resolvePlayUrl:String.fromEnvironment('TEST_VIDEO_URL') 非空且 video_url 为空或非 http 时替换;做成可选参数纯函数便于单测 |
app/lib/screens/feed/feed_screen.dart |
Feed 页骨架:策略 Tab + 搜索占位、PageView.builder、提示条挂载;WidgetsBindingObserver 退后台暂停 |
app/lib/screens/feed/widgets/feed_video_page.dart |
单页组装:播放器 + 手势层(单击暂停/继续、双击点赞)+ 互动栏 + 信息区 + 爱心动画层 |
app/lib/screens/feed/widgets/video_player_view.dart |
media_kit Video 包装:封面兜底(cached_network_image)、缓冲转圈、暂停态大播放图标 |
app/lib/screens/feed/widgets/interaction_bar.dart |
右侧栏:头像、点赞/评论/收藏/分享带计数;未登录跳登录;评论/分享 Toast 占位(二期) |
app/lib/screens/feed/widgets/video_info_section.dart |
左下:昵称、标题+#标签(本期仅样式不跳转)、描述展开/收起 |
app/lib/screens/feed/widgets/feed_overlays.dart |
双击爱心动画、FreeQuotaBanner(≤3 显示)、2002 登录引导与 2001 积分不足弹窗 |
修改(5 个,均小改)
| 文件 | 改动 |
|---|---|
app/lib/services/video_service.dart |
reportWatch 返回 WatchResult;新增 reportComplete(videoId, watchDuration) → bool |
app/lib/core/network/api_exception.dart |
增加 data 字段 + isGuestLimit(2002) / isPointsInsufficient(2001) getter |
app/lib/core/network/api_client.dart |
_UnwrapInterceptor 抛异常时透传 body['data'] |
app/lib/models/video_item.dart |
补 copyWith |
app/lib/screens/home/home_shell.dart |
改 StatefulWidget,Tab1 = FeedScreen,IndexedStack 保活,其余 Tab 占位 |
app/lib/router.dart |
redirect 放开 /home 游客访问 |
关键实现要点
计费链路(FeedNotifier.onShow)
onShow(index):
pool.bindWindow(index, items) // 先播放
item = items[index]
if dedup.contains(item.videoId) return // 同日已上报
dedup.mark(item.videoId) // 先标记,失败不回滚(防重复扣费)
try:
r = VideoService.reportWatch(item.videoId)
if r.costType == 1: 顶部 Toast「本次消耗 1 积分」
state.freeRemainingToday = r.freeRemainingToday // 驱动提示条
on ApiException e:
2002 → pool.pauseCurrent(); 弹登录引导(确认跳 /login)
2001 → pool.pauseCurrent(); 弹「积分不足」(去充值/开通会员,本期 Toast 占位)
其他 → 静默,不影响播放
- complete:pool 进度回调 →
reportComplete,走complete_去重集合;credited=false 无 UI。 - Toast 用简单 OverlayEntry 顶部条,不引第三方库。
互动乐观更新
未登录点击 → 跳 /login;已登录 → copyWith 立即更新 → 调接口 → 异常回滚 + Toast。双击:未点赞等同 toggleLike,已点赞只播动画。
免费剩余提示条
游客或(非会员且 remaining ≤ 3)时显示;初始 null 不显示,首次 watch 响应后落地。
验证方式
自动化(本机可跑):
WatchDedupStore单测:SharedPreferences.setMockInitialValues模拟,验证 mark/contains、跨日 key 清理WatchResult.fromJson解析单测(cost_type 0/1/2/3)resolvePlayUrl纯函数三分支单测- Widget 测试:InteractionBar 计数与回调、FreeQuotaBanner 显隐(remaining=2/5/null)、FeedVideoPage 暂停图标(pool 工厂注入 fake)
flutter analyze0 issues
有设备后人工验证(写进交接注释):注入测试流验证滑动播放/暂停/双击点赞;切策略 Tab;杀进程重进验证不重复扣费;mock 2002 弹登录引导;抓包确认 complete 只发一次。
不做(明确边界,防蔓延)
- 评论页、分享落地、关注、标签跳转(均二期,按钮 Toast 占位)
- 服务端 /hls 挂载与转码产物(上传/转码迭代)
- 去充值/开通会员页面(弹窗按钮 Toast 占位)