Files

7.1 KiB
Raw Permalink Blame History

K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 —— 计划(2026-10-16)

依据:docs/plans/INBOX.md 19:40 条目。用户指令:「现在你要做的事对接客户端接口及 UI 调整, 仍然严格按照 specs 执行」→ 本轮做客户端 ReelShort(Flutter),目标是一条完整看剧闭环: 首页点进剧 → 详情正确 → 能起播 → 进度能续看 → 能收藏,全链路走真接口。 契约以 FruitServer/docs/api-phase1.md §1.4 / §1.5 为准。

一、勘察更正(先说清,免得后面对不上)

条目里写「客户端只调了 6 个接口、没有详情和票据」—— 这条不准确,实测:

接口 现状
GET /app/dramas/{id} 已接:CatalogApi.dramaDetail + CatalogController.requireDetail,详情页在用
GET /app/episodes/{id}/play 半接:CatalogApi.playTicket + CatalogController.resolvePlay 存在,但没有任何页面调用;详情下发的 playUrl(免费集已带签名)被直接使用
/app/watch/* 没接:WatchHistoryRepository 是 SharedPreferences 本地实现,demoProfileId 假身份
/app/favorites/* 没接:收藏走本地 EngagementController(SharedPreferences),点赞也是本地(点赞属 K3,不动)

所以本轮真正要做的是:播放票据接上并带过期重取、进度从本地换远程、收藏换服务端、 三态 UI,以及把详情页已有的加载/失败态补齐(空态、续看进度条)。

二、方案(分层:api → repository → logic → view)

1. 新增 api 类(lib/core/data/remote/)

文件 方法 对应接口
playback_api.dart(新增) ticket(episodeId) GET /app/episodes/{id}/play
watch_api.dart(新增) saveProgress / progressOfDrama / recent POST /app/watch/progress、GET /app/watch/progress?dramaId=、GET /app/watch/recent?limit=
library_api.dart(新增) toggleFavorite / favorites / removeFavorite POST /app/favorites/toggle、GET /app/favorites、DELETE /app/favorites/{dramaId}

票据调用从 CatalogApi 搬到 PlaybackApi(条目要求新增 playback_api), CatalogRepository.resolvePlay 随之移到新的 PlaybackRepository——这是本轮唯一"动到既有目录层"的地方,最小接线。

2. repository(保留抽象,测试注入假实现)

  • playback_repository.dart(新增):ticketFor(episodeId, {forceRefresh})。 HttpPlaybackRepository 内含短时缓存:默认 TTL 10 分钟(签名有效期契约是 15 分钟,留余量), 时钟与 TTL 可注入 → 过期自动重取,测试用假时钟钉死。
  • watch_history_repository.dart(改签名):服务端按 (user, drama) 存一条续看记录, 所以接口从 (profileId, dramaId, episodeId) 变成 progressFor(dramaId) / save({episodeId, position, duration, finished}) / recent(limit); HttpWatchHistoryRepository + InMemoryWatchHistoryRepository(测试用); demoProfileId 删除;clampResumePosition 保留(续看钳位规则不变)。
  • favorite_repository.dart(新增):toggle / list / remove;HTTP + 内存实现。

3. controller / logic

  • favorite_controller.dart(新增):savedIds + cards + loading/error, toggle(dramaId) 乐观更新 → 失败回滚(DELETE/toggle 任一失败都回到原状态)。
  • DramaDetailLogic / MineLogic / PlayerLogic:改用新的 WatchHistoryRepository, 续看点从 WatchProgress(episodeId, positionMs) 来(不再是本地 episodeId 字符串)。
  • PlayerLogic:
    • 起播前 ticketFor(episode),把签名地址写回 session 的剧集对象;地址原样交给播放器 (AppConfig.mediaUrl 只补 host,不动 path/query —— 新增测试钉住 ?e=&s= 原样保留);
    • 票据拿不到 / accessType=COIN_REQUIRED|NO_MEDIA → 由后端返回值驱动 UI(锁定/无片源提示 + 重试);
    • 进度上报节流 10 秒(原实现是 5 秒),切集/退出/切后台各补一次,finished 在播完时上报。

4. UI

  • 详情页:加载中 / 失败(可重试)/ 空剧集三态;续看位置在"继续观看"按钮与对应剧集行上给进度条。
  • 播放页:票据加载中 / 失败(可重试)/ 锁定(accessType 驱动)三态;收藏按钮真实状态 + 回滚 + 失败提示(未登录先引导登录,沿用现有 ensureSignedIn)。
  • 我的页:最近观看卡片三态(服务端续看位置驱动)、收藏列表三态,去掉 demoProfileId 假身份。
  • 文案:新增键写进 app_en.arb / app_zh.arb / app_id.arb,flutter gen-l10n 重新生成。

三、影响面清单

新增:playback_api.dart、watch_api.dart、library_api.dart、watch_progress.dart(模型)、 playback_repository.dart、favorite_repository.dart、favorite_controller.dart。

改:watch_history_repository.dart(签名)、catalog_api.dart(去掉 playTicket)、 catalog_repository.dart(去掉 resolvePlay)、catalog_controller.dart(同步)、 play_ticket.dart(补 dramaId/number/previewSeconds)、app.dart(注入三个仓库 + FavoriteController)、 drama_detail_{logic,view,binding}.dart、mine_{logic,view,binding}.dart、 player_{logic,view,binding}.dart、discover_{logic,binding}.dart(续看点来源)、 app_{en,zh,id}.arb + 生成的 4 个 l10n 文件。

测试:改 test/support/fake_watch_history.dart、test/playback/watch_history_test.dart、 test/catalog/catalog_controller_test.dart、test/support/fake_catalog.dart、 test/{discover,mine,drama_detail,player}/…(续看点改签名);新增 playback_ticket_expiry_test.dart、progress_throttle_test.dart、watch_history_remote_test.dart、 favorite_controller_test.dart、player_ticket_gate_test.dart、integration/live_watch_flow_test.dart。

四、风险 / 待确认

  1. 改动面比"只加接口"大:WatchHistoryRepository 的签名必须改(服务端是"每剧一条续看记录", 没有 profile 概念),这会连带改 4 个 logic + 6 个测试文件。这是接口模型决定的,不是顺手重构。
  2. 未登录的边界:/app/watch/* 与 /app/favorites/* 需要登录态。客户端策略: 未登录时不发这些请求(浏览/试看仍可匿名),收藏按钮沿用"先引导登录"。
  3. 票据过期无法从响应判断(契约里没有 expiresAt 字段),所以用**客户端 TTL(10 分钟)**保守失效重取; 若后端后续补 expiresAt,直接换成按字段判断即可(口径已写进计划)。
  4. /app/watch/recent 只给 dramaId/episodeId/位置,没有剧名封面 —— K1 里"我的"页用 已加载的首页/发现卡片按 id 映射显示;映射不到就不显示(K2 的记录页再做成独立请求)。
  5. 联调测试需要登录态:新增的 live 测试会注册一个一次性邮箱账号,跑完在库里清掉(回执写明)。