# 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 测试会**注册一个一次性邮箱账号**,跑完在库里清掉(回执写明)。