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

93 lines
6.4 KiB
Markdown
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.
# 播放链路(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 占位)