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