Files
video/.trae/documents/feed-playback-plan.md
T
2026-10-15 20:36:59 +08:00

6.4 KiB
Raw Blame History

播放链路(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(布局与交互规范)。

架构决策

  1. 播放器 3 槽位池化(FeedPlayerPool):media_kit 每个 Player 是一个 mpv 实例,PageView 无限列表每页持有会 OOM。窗口 [i-1, i, i+1] 映射到 index % 3 槽位:当前 play、相邻 open 预缓冲、出窗 stop。
  2. watch 去重持久化(SharedPreferences):游客免费额度按设备计费,杀进程重进重复上报会白扣游客次数。key 按日分组 watch_yyyyMMdd,隔日惰性清理;complete 用 complete_ 前缀同理。
  3. 状态两层:feedProvider(family 按策略 recommend/latest/hot)管列表/游标/当前索引/freeRemainingToday;FeedPlayerPool 管播放器生命周期。计费触发收在 Notifier.onShow,UI 不直接调 service。
  4. router 放开 /home 游客访问:游客可进 Feed,超限(2002)才弹登录引导。
  5. 骨架小改: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 响应后落地。

验证方式

自动化(本机可跑):

  1. WatchDedupStore 单测:SharedPreferences.setMockInitialValues 模拟,验证 mark/contains、跨日 key 清理
  2. WatchResult.fromJson 解析单测(cost_type 0/1/2/3)
  3. resolvePlayUrl 纯函数三分支单测
  4. Widget 测试:InteractionBar 计数与回调、FreeQuotaBanner 显隐(remaining=2/5/null)、FeedVideoPage 暂停图标(pool 工厂注入 fake)
  5. flutter analyze 0 issues

有设备后人工验证(写进交接注释):注入测试流验证滑动播放/暂停/双击点赞;切策略 Tab;杀进程重进验证不重复扣费;mock 2002 弹登录引导;抓包确认 complete 只发一次。

不做(明确边界,防蔓延)

  • 评论页、分享落地、关注、标签跳转(均二期,按钮 Toast 占位)
  • 服务端 /hls 挂载与转码产物(上传/转码迭代)
  • 去充值/开通会员页面(弹窗按钮 Toast 占位)