Author SHA1 Message Date
Zhou Weidong a48433f01d fix(profile): 修复我的收藏与观看历史列表不显示 2026-10-22 13:09:55 +08:00
Zhou Weidong f2ae0c70b6 merge: 收藏卡片展示简介 2026-10-22 12:45:31 +08:00
Zhou Weidong 57f3029018 feat(library): 收藏卡片展示简介 2026-10-22 12:45:27 +08:00
Zhou Weidong 9093700a04 merge: 优化观看历史标题加载 2026-10-22 12:43:06 +08:00
Zhou Weidong 78114fa46d fix(library): 优化观看历史标题加载 2026-10-22 12:42:58 +08:00
Zhou Weidong abfca7a3be merge: 收藏与观看历史支持分页 2026-10-22 12:34:39 +08:00
Zhou Weidong 235b50598a feat(library): 收藏与观看历史支持分页 2026-10-22 12:34:27 +08:00
Zhou Weidong 63d3b0f7ca merge: 增加 Android Studio 调试 HTTP 日志 2026-10-22 12:16:02 +08:00
Zhou Weidong 7a588898fa feat(debug): 增加 HTTP 请求响应日志 2026-10-22 12:15:51 +08:00
Zhou Weidong dc4b60270d merge: 合并进度条雪碧图预览 2026-10-22 11:40:30 +08:00
Zhou Weidong 12b733d111 feat(player): 进度条拖动预览改用雪碧图 2026-10-22 11:40:23 +08:00
Zhou Weidong 89a94f85c2 merge: 合并进度条画面预览 2026-10-22 11:03:25 +08:00
Zhou Weidong 51c56d884b feat(player): 增加进度条拖动画面预览 2026-10-22 11:03:17 +08:00
Zhou Weidong d06dce9753 merge: 合并播放入口无缓冲提示 2026-10-22 10:47:28 +08:00
Zhou Weidong 7ad4681049 fix(player): 收敛播放入口缓冲提示 2026-10-22 10:47:16 +08:00
Zhou Weidong f302b396b7 merge: 合并 Seek 播放位置修复 2026-10-22 10:41:12 +08:00
Zhou Weidong 5268adc999 fix(player): 记住拖动后的实际播放位置 2026-10-22 10:41:01 +08:00
Zhou Weidong 47ca21406a merge: 合并播放位置连续性修复 2026-10-22 10:28:07 +08:00
Zhou Weidong 759a02375a fix(player): 修复详情往返播放位置丢失 2026-10-22 10:27:58 +08:00
71 changed files with 5721 additions and 309 deletions

No files matched your search

@@ -5,8 +5,13 @@ package com.stormg.lumadrama
import android.content.Intent
import android.app.PictureInPictureParams
import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Rational
import android.content.pm.PackageManager
import android.view.WindowManager
@@ -15,12 +20,19 @@ import androidx.core.view.WindowInsetsCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class MainActivity : FlutterActivity() {
private val shareRequestCode = 9041
private val thumbnailExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val thumbnailHandler = Handler(Looper.getMainLooper())
private var pendingShare: MethodChannel.Result? = null
private var pipChannel: MethodChannel? = null
private var keyboardChannel: MethodChannel? = null
private var thumbnailChannel: MethodChannel? = null
private var imeInset = 0
override fun onCreate(savedInstanceState: Bundle?) {
@@ -86,6 +98,47 @@ class MainActivity : FlutterActivity() {
emitImeInset(inset)
result.success(inset)
}
thumbnailChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.stormg.lumadrama/thumbnail"
)
thumbnailChannel?.setMethodCallHandler { call, result ->
if (call.method != "frame") {
result.notImplemented()
return@setMethodCallHandler
}
val timeMs = (call.argument<Number>("timeMs"))?.toLong()
if (timeMs == null) {
result.success(null)
return@setMethodCallHandler
}
val localPath = call.argument<String>("localPath").orEmpty()
val remoteUrl = call.argument<String>("url").orEmpty()
val rawHeaders = call.argument<Map<*, *>>("headers")
val headers = rawHeaders?.entries
?.associate { it.key.toString() to it.value.toString() }
.orEmpty()
val responded = AtomicBoolean(false)
val task = thumbnailExecutor.submit {
val bytes = renderThumbnail(
timeMs = timeMs,
localPath = localPath,
remoteUrl = remoteUrl,
headers = headers
)
thumbnailHandler.post {
if (responded.compareAndSet(false, true)) {
result.success(bytes)
}
}
}
thumbnailHandler.postDelayed({
if (responded.compareAndSet(false, true)) {
task.cancel(true)
result.success(null)
}
}, thumbnailTimeoutMs)
}
val decorView = window.decorView
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
@@ -106,6 +159,59 @@ class MainActivity : FlutterActivity() {
keyboardChannel?.invokeMethod("insetsChanged", value)
}
private fun renderThumbnail(
timeMs: Long,
localPath: String,
remoteUrl: String,
headers: Map<String, String>
): ByteArray? {
val retriever = MediaMetadataRetriever()
var frame: Bitmap? = null
var scaled: Bitmap? = null
return try {
when {
localPath.isNotEmpty() -> retriever.setDataSource(normalizeLocalPath(localPath))
remoteUrl.isNotEmpty() -> retriever.setDataSource(remoteUrl, headers)
else -> return null
}
frame = retriever.getFrameAtTime(
timeMs * 1000,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
)
val source = frame ?: return null
scaled = if (source.width > thumbnailMaxWidth) {
val targetHeight = (source.height * thumbnailMaxWidth / source.width)
.coerceAtLeast(1)
Bitmap.createScaledBitmap(
source,
thumbnailMaxWidth,
targetHeight,
true
)
} else {
source
}
val outputBitmap = scaled ?: source
val output = ByteArrayOutputStream()
val compressed = outputBitmap.compress(
Bitmap.CompressFormat.JPEG,
thumbnailJpegQuality,
output
)
if (!compressed) return null
output.toByteArray()
} catch (_: Exception) {
null
} finally {
if (scaled != null && scaled !== frame) scaled.recycle()
frame?.recycle()
retriever.release()
}
}
private fun normalizeLocalPath(path: String): String =
if (path.startsWith("file:")) Uri.parse(path).path ?: path else path
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode)
@@ -124,6 +230,16 @@ class MainActivity : FlutterActivity() {
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
keyboardChannel?.setMethodCallHandler(null)
keyboardChannel = null
thumbnailChannel?.setMethodCallHandler(null)
thumbnailChannel = null
thumbnailHandler.removeCallbacksAndMessages(null)
thumbnailExecutor.shutdownNow()
super.onDestroy()
}
companion object {
private const val thumbnailMaxWidth = 240
private const val thumbnailJpegQuality = 70
private const val thumbnailTimeoutMs = 1500L
}
}
@@ -0,0 +1,24 @@
# Android Studio 调试 HTTP 日志
## 目标
- 仅在调试构建中输出 Dio 请求、响应与异常日志。
- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。
- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。
- 单条请求体或响应体最多 3000 字符。
## 实现步骤
1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。
2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。
3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。
4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。
5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。
## 风险与约束
- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。
- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取,
避免拿到已解包后的 `data`。
- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。
- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。
@@ -0,0 +1,20 @@
# 收藏卡片简介
## 目标
- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。
- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。
- 保持封面 80x106 和现有卡片高度,不引入布局溢出。
## 实现
1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。
2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间,
`maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。
3. 无简介时继续使用原 Spacer,保持原布局。
4. widget 测试覆盖有简介、空简介和卡片高度稳定性。
## 约束
- 不改接口、后端、收藏分页。
- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。
@@ -0,0 +1,31 @@
# 收藏与观看历史分页 UI
## 目标
- `/app/favorites` 与 `/app/watch/recent` 按 `PageResult{total,list}` 分页接入。
- 收藏页、观看历史页支持首屏、触底加载、无更多、刷新、失败重试和总数展示。
- 观看记录直接使用后端 `dramaTitle/dramaCover/episodeNumber`,缺失时安全兜底。
## 实现步骤
1. 新增通用 `PageResult<T>`,更新 LibraryApi、WatchApi、Repository 与内存替身。
2. FavoriteController、WatchRecordsController 增加分页状态、请求代次、dramaId 去重。
3. 页面通过滚动通知触发 `loadMore`,复用下拉刷新并增加通用分页 footer。
4. 观看卡片优先渲染接口字段,标题为空时用本地化“第 N 集”,封面为空显示占位。
5. 补 API、Controller 和 widget 测试,运行 analyze 与全量 flutter test。
## 风险与约束
- 刷新期间保持旧列表,成功后原子替换并回到第 1 页;失败保留旧数据。
- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。
- 空页立即停止加载;`loaded >= total` 后不再请求。
- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。
## 2026-10-22 范围修订
- 分页协议和 Controller 分页状态保持不改。
- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。
- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。
- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。
- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页,
只在 PROGRESS 记录证据,不做前端全量兜底。
@@ -0,0 +1,21 @@
# 播放入口无缓冲提示
## 目标
- 未初始化进度条显示静态 inactive 轨道,不出现流动加载高光。
- 仅真实 buffering 持续超过 350ms 时显示白色高光。
- 起播定位阶段不叠加 session 居中 loading;只有 ticket loading 居中显示。
- 同集返回复用 controller;重建时 initialize→seek→play。
## 实施步骤
1. 调整 SeekProgressBar 与 _FeedProgressBar 的初始化/缓冲状态判断。
2. 删除 PlayerView 的 session loading 居中圈,仅保留 ticket loading。
3. preparePlayer 统一通过 VideoPlayerPort.seekTo 定位缓存位置。
4. 更新进度条、全屏入口、详情返回和重建顺序测试。
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 真实持续网络卡顿仍保留缓冲高光。
- 锁定集继续沿用静态空轨道和禁用 seek 行为。
@@ -0,0 +1,32 @@
# 播放位置连续性
## 目标
- 首页进入详情、详情返回首页、详情再进播放器时保持同一集同一秒。
- 新增进程内最近播放位置缓存,优先于服务端旧记录。
- 跨剧、跨集不串位;锁定/票据失败不因缓存强行起播;进程重启仍回服务端。
## 实施步骤
1. 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId/position/duration/updatedAt。
2. App Provider 注入单一缓存实例,PlayerPage 与详情页共享。
3. PlayerLogic 进度监听、saveCurrentProgress 写入缓存;loadResumePoint 优先命中缓存。
4. preparePlayer 对每个待准备集按 dramaId 校验缓存,并继续使用现有位置钳位。
5. 详情页续看状态优先使用该剧最新缓存,未命中再请求服务端。
6. 补缓存、缓存命中不请求服务端、跨剧隔离、服务端回落和详情返回位置测试。
7. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 缓存仅保存在进程内,App 重启后自动回落到服务端观看记录。
- 片尾附近继续复用现有 `clampResumePosition` 完成保护。
## 执行结果
- 新增进程内按集缓存并通过 Provider 全局共享,详情页与播放器共用。
- 进度监听、`saveCurrentProgress` 和进入详情前会写入缓存。
- `loadResumePoint` 优先缓存,未命中或跨剧时才请求服务端。
- 详情页继续观看与 CTA 优先进缓存集;返回首页保持原播放器与位置。
- 已覆盖缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落和详情往返。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 589 项全过。
- 未启停服务,未操作真机。
@@ -0,0 +1,21 @@
# Seek 后播放位置保持
## 目标
- 拖动进度条后,缓存与观看记录立即使用 seek 完成后的真实位置。
- 拖动后立即进详情/离开也不会回退到旧进度。
- 越界位置 clamp,锁定/票据失败不写无效缓存。
## 实施步骤
1. 增加播放位置 clamp 纯函数,缓存写入统一限制在 `0..duration`。
2. VideoPlayerPort 暴露 seek 方法,PlayerLogic 等待 seek 完成后再读取 controller。
3. PlayerLogic 统一当前播放快照,监听、缓存和上报共用同一份 position/duration。
4. 增加 pending seek 屏障,saveCurrentProgress 与离开路径等待 seek 和上报完成。
5. 补 seek 缓存、越界 clamp、seek 后进详情并返回测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 片尾与完成逻辑继续沿用现有 controller/播放器行为。
- 缓存仅在进程内,不改变服务端观看记录契约。
@@ -0,0 +1,62 @@
# 播放器进度拖动画面预览
## 目标
首页 feed 与全屏播放器的进度条在拖动时显示当前时间点的视频画面缩略图、
当前时间与总时长;松手立即隐藏并提交 seek。缩略图未返回或失败时显示短剧
封面,不出现空白闪烁或错误文案。
## 性能红线
1. 拖动期间主线程无卡顿;图片解码和原生取帧不得阻塞 UI 线程。
2. 原生取帧并发数不超过 1。
3. 预览图最大宽度 240px。
4. Dart 内存 LRU 缓存不超过 24 帧。
5. 不新增 pub 依赖。
6. 不改后端契约。
## 实现
### 缩略图服务
- 新增 `PlaybackThumbnailService`,公开入口按 `episodeId + position` 取
`ByteData`,底层通过
`MethodChannel('com.stormg.lumadrama/thumbnail')` 调用原生。
- 时间按 1 秒分桶;同一 bucket 的在途请求合流。
- 最多 24 帧 LRU;成功和失败结果都做过期控制,避免旧拖动位置覆盖当前预览。
- 全局单飞;拖动期间只保留最新 bucket,尚未开始的旧请求直接丢弃。
- 拖动停顿 120ms 后最多预取相邻一个 bucket,松手取消尚未开始的请求。
- 原生异常、缺失实现和超过 1.5 秒无结果统一返回 `null`。
### 原生取帧
- Android 使用 `MediaMetadataRetriever`,优先本地下载路径,其次签名 URL;
`OPTION_CLOSEST_SYNC` 取帧,最大宽 240px,JPEG 质量 70,单线程 Executor,
完成后立即 release。
- iOS 使用 `AVAssetImageGenerator`,应用画面方向,最大尺寸约 240x426,
合理时间容差,后台串行队列生成 JPEG。
- 两端均保证只回调一次;失败或 1.5 秒超时返回 `null`,不向 UI 抛异常。
### UI
- 新增 `ScrubPreviewCard`:宽 120dp、圆角 8、白色 1px 描边,显示
`当前时间 / 总时长`;沿拖动点水平跟随并做安全边距 clamp。
- 首页 `_FeedProgressBar` 与全屏 `SeekProgressBar` 共用该卡片。
- 封面始终铺在卡片底层;`Image.memory` 仅在帧到达后覆盖,并限制
`cacheWidth: 240`。
- 开始拖动请求当前 bucket,拖动更新只保留最新位置;松手立即隐藏卡片并取消
未开始请求。
- `PlayerPage` 持有页面级服务,dispose 时清空缓存,避免跨页面串集。
## 测试
- 单测:bucket、LRU 淘汰、同 bucket 合流、过期请求丢弃、松手取消、
相邻 bucket 防抖、异常/超时回落。
- Widget:拖动显示预览卡片和时间、松手消失、无缩略图显示封面。
- 门禁:`dart analyze lib test` 与 `flutter test --no-pub`。
## 非目标
- 不改服务端接口或持久化策略。
- 不新增第三方依赖。
- 不启停服务、不操作真机;原生取帧实际效果由 PM 真机验收。
@@ -0,0 +1,46 @@
# 雪碧图 VTT 拖动预览
## 目标
将进度条拖动预览主链路改为服务端雪碧图 + WebVTT 局部裁剪,原生取帧只作为降级:
1. `Episode` 与 `PlayTicket` 解析 `thumbnailsUrl`。
2. VTT 只拉取/解析一次,内存缓存 10 分钟。
3. cue 使用起止时间 + 相对 sprite 文件名 + `xywh`,按 position 二分选择。
4. sprite 图最多缓存 2 张 `ui.Image`,拖动帧只绘制 sourceRect。
5. 无 VTT、空轴或图片失败时回退现有 `PlaybackThumbnailService`,再回退封面。
## 性能红线
- 拖动帧禁止网络请求与图片解码,只允许 cue 二分查找和 `RawImage.sourceRect` 绘制。
- VTT 每个 URL 只解析一次;sprite 解码不在 build/build paint 中执行。
- sprite ui.Image LRU 上限 2 张,页面 dispose 清空。
- 解码 sprite 最长边超过 2048px 时按比例缩小后常驻。
- 签名 `thumbnailsUrl` 不写入任何持久化缓存。
## 实施
- 新增 `SpriteThumbnailAxisService`:
- Dio 拉取 VTT,403 清缓存后重试一次;
- 解析和选 cue 为纯函数,解析失败返回空轴;
- 播放器就绪时预取当前 cue 及相邻 sprite;
- LRU2 sprite 缓存和 10 分钟 VTT 缓存。
- `PlayerPage` 持有页面级服务,按当前选中集预取,dispose 时清空。
- `ScrubPreviewCard` 优先读取已缓存 sprite frame,显示 `RawImage` 裁剪;
VTT 未就绪时暂显封面,轴为空/不可用后启动本地取帧,全部失败保留封面。
- 首页 feed 和全屏播放器都把 `thumbnailsUrl` 传到预览卡片。
- 不新增 pub 依赖、不改后端契约。
## 测试
- VTT 解析:时间、xywh、多 sprite 文件。
- cue 选择:命中、越界最近邻。
- sprite LRU 最多 2 张。
- 回退状态判定。
- Widget:sprite 裁剪且不触发本地取帧;无 URL 回退本地;全部失败显示封面。
- 门禁:`dart analyze lib test`、`flutter test --no-pub`。
## 风险
- 真实网络 VTT/sprite 可访问性和签名过期由 PM 真机验收。
- 本次不启动服务、不操作真机、不修改 Web/FruitServer。
+404
View File
@@ -326,3 +326,407 @@ $ flutter test --no-pub
解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停
均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原,
未启停服务,未操作真机。
## 2026-10-22 播放位置连续性
- 已从 `develop`(`fe49c6d`)创建 `feature/playback-position-continuity`。
- 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId、position、duration、updatedAt。
- App Provider 注入单一缓存实例,PlayerLogic 与详情页共享。
- 进度监听、保存进度与进入详情前更新缓存;loadResumePoint 优先缓存,未命中再回源。
- 详情页继续观看与 CTA 优先使用最近缓存集,返回首页保持当前播放器位置。
- 跨剧缓存不命中;片尾保护继续复用 `clampResumePosition`。
执行命令与原始输出:
```text
$ flutter test --no-pub test/playback/playback_position_cache_test.dart
00:00 +4: All tests passed!
$ flutter test --no-pub test/player/player_logic_test.dart
00:00 +38: All tests passed!
$ flutter test --no-pub test/player/player_open_detail_test.dart
00:00 +1: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:53 +589: All tests passed!
```
解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集
和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。
`pubspec.lock` 已还原,未启停服务,未操作真机。
## 2026-10-22 Seek 后播放位置保持
- 已从 `develop`(`47ca214`)创建 `feature/playback-seek-position`。
- 新增播放位置 clamp;缓存、上报与详情返回统一使用 seek 完成后的 controller 位置。
- PlayerLogic 增加 pending seek 屏障,saveCurrentProgress 与离开路径会等待 seek 和写入完成。
- 监听器统一读取一次当前播放快照,避免旧位置覆盖新位置。
- 已补 seek 目标缓存、越界 clamp、seek 后立即进详情并返回的位置测试。
执行命令与原始输出:
```text
$ flutter test --no-pub test/playback/playback_position_cache_test.dart
00:00 +5: All tests passed!
$ flutter test --no-pub test/player/player_logic_test.dart --plain-name should_cache_actual_position_after_seek_completes
00:00 +1: All tests passed!
$ flutter test --no-pub test/player/player_open_detail_test.dart
00:00 +1: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:49 +591: All tests passed!
```
解读:seek 完成后缓存与上报使用实际位置,越界目标被 clamp;seek 后立即
进详情并返回仍保持目标位置。全量静态分析零告警,591 项测试全过。
`pubspec.lock` 已还原,未启停服务,未操作真机。
## 2026-10-22 播放入口无缓冲提示
- 已从 Seek 修复合并后的 `develop`(`f302b39`)创建 `feature/playback-entry-no-buffer`。
- 未初始化进度条改为静态 inactive 轨道,不显示流动高光且不可拖动。
- 仅 initialized 且 isBuffering 持续超过 350ms 时显示白色缓冲高光。
- PlayerView 删除 session 居中 loading,只保留 ticket loading。
- preparePlayer 改为初始化完成后先 seek 缓存位置再 play;详情返回复用原 controller。
执行命令与原始输出:
```text
$ flutter test --no-pub test/playback/seek_progress_bar_test.dart
00:00 +7: All tests passed!
$ flutter test --no-pub test/home/home_preview_layout_test.dart
00:01 +8: All tests passed!
$ flutter test --no-pub test/player/player_logic_test.dart
00:00 +40: All tests passed!
$ flutter test --no-pub test/player/player_fullscreen_test.dart
00:09 +37: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:49 +593: All tests passed!
```
解读:短缓冲不闪、持续缓冲仍显示白色高光;未初始化/入口/返回/重建路径
均无多余 loading,重建顺序为 initialize→seek→play。全量静态分析零告警,
593 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
## 2026-10-22 播放器进度拖动画面预览
- 已从最新 `develop`(`d06dce9`)创建 `feature/scrub-frame-preview`。
- 计划文档:`docs/plans/2026-10-22-scrub-frame-preview.md`。
- 性能红线:拖动期间主线程无卡顿;原生并发取帧不超过 1;预览图最大宽
240px;Dart LRU 缓存不超过 24 帧;不新增 pub 依赖;不改后端契约。
- 实施顺序:缩略图服务与缓存 -> 预览卡片及两处进度条 -> Android/iOS
原生渠道 -> 单测/widget 测试与全量门禁。
```text
$ git status --short --branch
## feature/scrub-frame-preview
$ git log -5 --oneline --decorate
d06dce9 (HEAD -> feature/scrub-frame-preview, origin/develop, develop) merge: 合并播放入口无缓冲提示
```
解读:工作树从最新 develop 创建且开工前无未提交改动。当前仅新增计划与
PROGRESS 留痕,后续每完成一个阶段会补充命令原始输出与解读。
### 实现结果
- 新增 `PlaybackThumbnailService`:1 秒 bucket、24 帧 LRU、单飞合流、
最新 bucket 优先、停顿 120ms 后最多预取相邻 1 帧、1.5 秒统一回落。
- 新增 `ScrubPreviewCard`:120dp 宽、圆角 8、白色 1px 描边,显示
`当前时间 / 总时长`,安全边距内跟随拖动点;帧到达前始终显示封面。
- 首页 feed 与全屏播放器进度条均接入;松手立即隐藏并取消尚未开始的请求。
- Android 使用单线程 `MediaMetadataRetriever`,iOS 使用串行
`AVAssetImageGenerator`;两端优先本地路径、最大宽 240px、JPEG 约 70、
失败或 1.5 秒超时回调 null。
- 未新增 pub 依赖,未改后端契约;`pubspec.lock` 已还原,未启停服务,
未操作真机。
执行命令与原始输出:
```text
$ flutter test --no-pub test/playback/playback_thumbnail_service_test.dart test/playback/seek_progress_bar_test.dart test/home/home_preview_layout_test.dart
00:01 +28: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:56 +606: All tests passed!
```
解读:bucket、24 帧 LRU、同 bucket 合流、过期请求丢弃、松手取消、
120ms 预取防覆盖、异常/超时回落、首页与全屏预览卡片、封面占位和松手立即
消失均通过;全量静态分析零告警,606 项测试全过。性能红线保持:主线程不做
图片解码/取帧、原生并发 1、预览最大宽 240px、缓存最多 24 帧、不增依赖、
不改后端。原生取帧真实画面与设备性能由 PM 真机验收。
## 2026-10-22 雪碧图 VTT 拖动预览
- 已从 `develop`(`89a94f8`)创建 `feature/sprite-scrub-preview`。
- 计划文档:`docs/plans/2026-10-22-sprite-scrub-preview.md`。
- 性能红线:拖动帧不发起网络/解码,只做 cue 二分与 sourceRect 绘制;
VTT 缓存 10 分钟且只解析一次;sprite ui.Image 最多 2 张;超过 2048px
按比例缩放;签名地址不进入持久化缓存。
- 回退链:雪碧图 -> 现有本地原生取帧 -> 集封面。
```text
$ git status --short --branch
## feature/sprite-scrub-preview
$ git rev-parse --short HEAD
89a94f8
```
解读:工作树从指定最新 develop 创建,开工前无未提交改动;主工作区现有
`pubspec.lock` 未复制、未修改。
### 实现结果
- `Episode` / `PlayTicket` 增加 nullable `thumbnailsUrl`;票据写回和详情来源都保留,
本地下载只覆盖视频地址,不丢 VTT;签名地址未进入持久化缓存。
- 新增 `SpriteThumbnailAxisService`:Dio VTT 拉取、10 分钟缓存、403 清缓存重试一次、
cue 解析与二分、sprite `ui.Image` LRU2、2048px 解码上限。
- `PlayerPage` 在选中集就绪时预取当前及相邻 sprite;dispose 释放 VTT 与图片。
- `ScrubPreviewCard` 优先用 `CustomPaint.drawImageRect` 绘制 `sourceRect`;
VTT 未就绪显示封面,轴为空/加载失败回退本地原生取帧,再失败保留封面。
- 拖动帧只做缓存查询、二分、矩形裁剪与 drawImageRect,不发起网络或图片解码。
执行命令与原始输出:
```text
$ flutter test --no-pub test/playback/sprite_thumbnail_axis_test.dart test/player/scrub_preview_card_test.dart
00:00 +11: All tests passed!
$ flutter test --no-pub test/playback/seek_progress_bar_test.dart test/home/home_preview_layout_test.dart test/player/player_fullscreen_test.dart
00:10 +55: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:54 +617: All tests passed!
```
解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、
回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析
零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
## 2026-10-22 Android Studio 调试 HTTP 日志
- 分支:`feature/debug-http-logging`
- 基线:`develop` / `origin/develop` = `dc4b602`
- 计划:`docs/plans/2026-10-22-debug-http-logging.md`
```text
$ git status --short --branch
## develop...origin/develop
M pubspec.lock
$ git rev-parse HEAD
dc4b60270dd38d594510b45bd7a3d50b7dcd2602
```
解读:基于最新 develop 创建功能分支;开工时 `pubspec.lock` 已有未提交改动,
本次未修改、未暂存该文件。
### 实现结果
- 新增 `NetworkLogInterceptor`,默认 `debugPrint`,可注入
`void Function(String)` 作为单测日志收集器。
- `enabled` 常量与 `kDebugMode` 同时为真才输出,release/profile 零 HTTP 日志。
- 请求行包含完整 URL 与 query,同时输出耗时起点、脱敏请求头和格式化请求体。
- Authorization 仅保留前 8 字符后加 `...`;X-Device-Id 原样输出;
其余请求头值统一 `***`。
- ResponseType.bytes 响应统一 `utf8.decode(..., allowMalformed: true)`,
JSON 自动缩进;图片/音视频/字体/PDF/ZIP 等只输出 MIME 与字节数。
- 请求体和响应体超过 3000 字符时截断并追加 `…(truncated)`。
- 拦截链为 `AuthHeaderInterceptor → EnvelopeInterceptor → NetworkLogInterceptor`。
Envelope 在解包前暂存原始 bytes,末尾日志读取暂存值,因此仍能打印 Envelope
处理前的原始响应。
- 异常路径输出 `[HTTP] ✗ DioException类型 耗时ms URL message`,并继续
`handler.next(err)`,不吞异常。
代表性原始日志:
```text
[HTTP] → POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
[HTTP] → 耗时起点:2026-10-22T12:12:25.377357
[HTTP] → headers:{"Accept":"***","Authorization":"Bearer e...","X-Device-Id":"device-raw-001"}
[HTTP] → body:{
"filter": "中文"
}
[HTTP] ← 200 12ms POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
[HTTP] ← body:{
"code": 0,
"message": "ok",
"data": {
"title": "中文标题"
}
}
```
### 门禁
```text
$ flutter test --no-pub test/core/data/remote/network_log_interceptor_test.dart
00:00 +0: should_log_request_and_response_lines_with_elapsed_when_requested
00:00 +1: should_truncate_long_body_when_log_exceeds_limit
00:00 +2: should_log_plain_text_without_throwing_when_response_is_not_json
00:00 +3: should_log_byte_count_only_when_response_is_image
00:00 +4: should_log_dio_exception_type_and_message_when_request_fails
00:00 +5: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:58 +622: All tests passed!
```
解读:请求/响应行与耗时、Authorization 脱敏、X-Device-Id 原样、bytes 中文
UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 类型与 message
均通过;全量静态分析零告警,622 项测试全过。
未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。
## 2026-10-22 收藏与观看历史分页 UI
- 分支:`feature/library-paging-ui`
- 基线:`develop` / `origin/develop` = `63d3b0f`
- 后端依赖:FruitServer `7b69493`(收藏/观看历史 PageResult 与展示字段)
- 计划:`docs/plans/2026-10-22-library-paging-ui.md`
### 实现
- 新增通用 `PageResult<T>`;`LibraryApi.favorites` 与 `WatchApi.recent`
统一请求 `pageIndex/pageSize` 并返回 `PageResult`。
- 收藏与观看仓储、Controller 增加首屏、刷新、loadMore、total、hasMore、
loadingMore 和 loadMoreError;请求代次避免旧刷新/旧分页覆盖新列表。
- 收藏按 dramaId 去重,观看按 dramaId 去重;空页或 `loaded >= total` 停止请求。
- 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。
- 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`;
标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。
- API 严格解析服务端 PageResult;旧 List 不做客户端全量包装,由后端部署新契约解决。
- MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。
### 门禁
定向测试:
```text
$ flutter test --no-pub test/library/library_api_test.dart test/library/favorite_controller_test.dart test/library/favorites_page_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart test/mine/mine_logic_test.dart
00:03 +40: All tests passed!
```
完整门禁:
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:56 +632: All tests passed!
```
解读:分页第一页、追加去重、无更多不重复请求、loadMore 失败重试、
观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。
未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。
### 范围修订:观看标题优先与按需详情
- 分页协议、Repository 与 Controller 分页状态保持不变。
- `_hydrate` 只对 `dramaTitle` 为空的记录补拉详情;后端已有标题时不再请求详情。
- 有标题的记录点击播放时才调用 `detailForOpen` 按需加载详情,避免列表阶段 N+1。
- 标题仍为空时使用本地化“第 N 集”,封面缺失继续显示占位。
- 移除了旧 List 自动包装为 PageResult 的前端全量兜底,避免掩盖后端契约问题。
真实后端分页取证(仅测试账号写入,不启停服务):
```text
GET /app/watch/recent?pageIndex=1&pageSize=1
{"shape":"list","length":2,"dramaIds":["1","5"]}
GET /app/watch/recent?pageIndex=2&pageSize=1
{"shape":"list","length":2,"dramaIds":["1","5"]}
GET /app/favorites?pageIndex=1&pageSize=1
{"shape":"list","length":2,"dramaIds":["1","5"]}
GET /app/favorites?pageIndex=2&pageSize=1
{"shape":"list","length":2,"dramaIds":["1","5"]}
```
解读:当前 8081 运行实例仍是旧包,返回 List 且忽略 pageIndex/pageSize;第 1/2 页
均返回相同两条数据。客户端不做全量兜底;live smoke 检测到旧 List 后明确标记 skipped,
等待后端新包部署后再执行分页真实联调。
门禁:
```text
$ flutter test --no-pub test/integration/live_watch_flow_test.dart test/library/library_api_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart
00:02 +22 ~1: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:52 +630 ~1: All tests passed!
```
解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、
第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务
能力探测跳过。`pubspec.lock` 既有改动未提交。
## 2026-10-22 收藏卡片简介
- 分支:`feature/favorite-card-synopsis`
- 基线:`develop` / `origin/develop` = `9093700`
- 计划:`docs/plans/2026-10-22-favorite-card-synopsis.md`
### 实现
- `_FavoriteCard` 在标题/标签下方条件渲染 `drama.synopsis.trim()`。
- 简介最多 2 行、`TextOverflow.ellipsis`,使用 `AppColors.muted`、12 号字和
`1.15` 行高,由 `Flexible` 占据剩余空间,保持封面与卡片原高度。
- 简介为空或纯空白时不创建文本节点,继续使用原 Spacer,不占高度。
- 未改接口、后端和收藏分页逻辑。
### 门禁
```text
$ flutter test --no-pub test/library/favorites_page_test.dart
00:01 +7: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:51 +631 ~1: All tests passed!
```
解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致;
全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。
`pubspec.lock` 既有改动未提交。
+85
View File
@@ -1,5 +1,24 @@
import Flutter
import UIKit
import AVFoundation
private final class ThumbnailResponseGate {
private let lock = NSLock()
private var completed = false
func send(_ result: @escaping FlutterResult, data: Data?) {
lock.lock()
if completed {
lock.unlock()
return
}
completed = true
lock.unlock()
DispatchQueue.main.async {
result(data)
}
}
}
/// Keeps the system LaunchScreen as the only splash. Flutter would otherwise
/// instantiate that storyboard again and the icon appears a second time.
@@ -16,6 +35,11 @@ final class RunnerFlutterViewController: FlutterViewController {
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private let thumbnailQueue = DispatchQueue(
label: "com.stormg.lumadrama.thumbnail",
qos: .userInitiated
)
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -44,5 +68,66 @@ final class RunnerFlutterViewController: FlutterViewController {
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
controller.present(activity, animated: true)
}
let thumbnailChannel = FlutterMethodChannel(
name: "com.stormg.lumadrama/thumbnail",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
thumbnailChannel.setMethodCallHandler { [weak self] call, result in
guard call.method == "frame" else {
result(FlutterMethodNotImplemented)
return
}
guard let self,
let arguments = call.arguments as? [String: Any],
let timeNumber = arguments["timeMs"] as? NSNumber else {
result(nil)
return
}
let localPath = arguments["localPath"] as? String ?? ""
let remoteUrl = arguments["url"] as? String ?? ""
let headers = arguments["headers"] as? [String: String] ?? [:]
let assetUrl: URL?
if !localPath.isEmpty {
assetUrl = localPath.hasPrefix("file:")
? URL(string: localPath)
: URL(fileURLWithPath: localPath)
} else if !remoteUrl.isEmpty {
assetUrl = URL(string: remoteUrl)
} else {
assetUrl = nil
}
guard let assetUrl else {
result(nil)
return
}
let options: [String: Any]? = headers.isEmpty
? nil
: [AVURLAssetHTTPHeaderFieldsKey: headers]
let asset = AVURLAsset(url: assetUrl, options: options)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 240, height: 426)
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.5, preferredTimescale: 600)
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.5, preferredTimescale: 600)
let time = CMTime(value: timeNumber.int64Value, timescale: 1000)
let gate = ThumbnailResponseGate()
self.thumbnailQueue.async {
do {
let image = try generator.copyCGImage(at: time, actualTime: nil)
let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.7)
gate.send(result, data: data)
} catch {
gate.send(result, data: nil)
}
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + 1.5
) {
gate.send(result, data: nil)
}
}
}
}
+7
View File
@@ -42,6 +42,7 @@ import 'core/data/services/favorite_controller.dart';
import 'core/data/services/interaction_controller.dart';
import 'core/data/services/locale_controller.dart';
import 'core/data/services/event_tracker.dart';
import 'core/data/services/playback_position_cache.dart';
import 'core/data/services/playback_session.dart';
import 'core/data/services/video_player_port.dart';
import 'core/data/services/wallet_controller.dart';
@@ -72,6 +73,7 @@ class LumaDramaApp extends StatefulWidget {
this.watchHistoryRepository,
this.favoriteRepository,
this.playbackRepository,
this.playbackPositionCache,
this.commentRepository,
this.interactionRepository,
this.noticeRepository,
@@ -91,6 +93,7 @@ class LumaDramaApp extends StatefulWidget {
final WatchHistoryRepository? watchHistoryRepository;
final FavoriteRepository? favoriteRepository;
final PlaybackRepository? playbackRepository;
final PlaybackPositionCache? playbackPositionCache;
final CommentRepository? commentRepository;
final InteractionRepository? interactionRepository;
final NoticeRepository? noticeRepository;
@@ -146,6 +149,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
widget.playbackRepository ??
HttpPlaybackRepository(PlaybackApi(_apiClient));
late final PlaybackPositionCache _playbackPositionCache =
widget.playbackPositionCache ?? PlaybackPositionCache();
late final FavoriteController _favoriteController = FavoriteController(
_favorites,
);
@@ -240,6 +246,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
Provider<CatalogRepository>.value(value: _catalogRepository),
Provider<PlaybackRepository>.value(value: _playback),
Provider<PlaybackPositionCache>.value(value: _playbackPositionCache),
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository),
+25 -12
View File
@@ -17,6 +17,7 @@ class Episode {
this.locked = false,
this.likeCount,
this.commentCount,
this.thumbnailsUrl,
});
final String id;
@@ -32,19 +33,25 @@ class Episode {
final bool locked;
final int? likeCount;
final int? commentCount;
final String? thumbnailsUrl;
Episode withVideoUrl(String url, {int? likeCount, int? commentCount}) =>
Episode(
id: id,
number: number,
title: title,
isFree: isFree,
videoUrl: url,
durationSec: durationSec,
locked: locked,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
);
Episode withVideoUrl(
String url, {
int? likeCount,
int? commentCount,
String? thumbnailsUrl,
}) => Episode(
id: id,
number: number,
title: title,
isFree: isFree,
videoUrl: url,
durationSec: durationSec,
locked: locked,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
thumbnailsUrl: thumbnailsUrl ?? this.thumbnailsUrl,
);
factory Episode.fromJson(Map<String, dynamic> json) {
return Episode(
@@ -57,6 +64,7 @@ class Episode {
videoUrl: json['playUrl'] as String? ?? '',
likeCount: _optionalInt(json['likeCount']),
commentCount: _optionalInt(json['commentCount']),
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
);
}
}
@@ -159,3 +167,8 @@ class Drama {
}
int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value);
String? _optionalString(dynamic value) {
final text = value is String ? value.trim() : '';
return text.isEmpty ? null : text;
}
+47
View File
@@ -0,0 +1,47 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端标准分页响应 `{total, list}`。
///
/// 兼容旧版「data 直接是数组」的响应:按数组长度作为 total,避免列表空白。
class PageResult<T> {
const PageResult({this.total = 0, this.list = const []});
final int total;
final List<T> list;
/// 解析分页壳,条目由调用方提供具体解析函数。
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) parseItem,
) {
final items = _parseItemMaps(json['list']).map(parseItem).toList(
growable: false,
);
return PageResult(total: jsonToInt(json['total']), list: items);
}
/// 兼容分页 Map 与旧版纯 List;其它类型视为空页。
factory PageResult.parse(
Object? data,
T Function(Map<String, dynamic>) parseItem,
) {
if (data is Map) {
return PageResult.fromJson(Map<String, dynamic>.from(data), parseItem);
}
if (data is List) {
final items = _parseItemMaps(data).map(parseItem).toList(growable: false);
return PageResult(total: items.length, list: items);
}
return PageResult<T>();
}
}
List<Map<String, dynamic>> _parseItemMaps(Object? raw) {
if (raw is! List) return const [];
return [
for (final item in raw)
if (item is Map) Map<String, dynamic>.from(item),
];
}
+8
View File
@@ -14,6 +14,7 @@ class PlayTicket {
this.unlockCoin = 0,
this.previewSeconds = 0,
this.qualities = const [],
this.thumbnailsUrl,
this.likeCount,
this.commentCount,
});
@@ -40,6 +41,7 @@ class PlayTicket {
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
final List<String> qualities;
final String? thumbnailsUrl;
final int? likeCount;
final int? commentCount;
@@ -62,6 +64,7 @@ class PlayTicket {
.whereType<String>()
.where((quality) => quality.isNotEmpty)
.toList(),
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
likeCount: json['likeCount'] == null
? null
: jsonToInt(json['likeCount']),
@@ -71,3 +74,8 @@ class PlayTicket {
);
}
}
String? _optionalString(dynamic value) {
final text = value is String ? value.trim() : '';
return text.isEmpty ? null : text;
}
+20
View File
@@ -12,6 +12,10 @@ class WatchProgress {
required this.episodeId,
required this.position,
required this.duration,
this.dramaTitle,
this.dramaCover,
this.episodeNumber,
this.totalEpisodes,
this.finished = false,
this.playCount = 0,
this.updatedAt,
@@ -19,6 +23,10 @@ class WatchProgress {
final String dramaId;
final String episodeId;
final String? dramaTitle;
final String? dramaCover;
final int? episodeNumber;
final int? totalEpisodes;
final Duration position;
final Duration duration;
final bool finished;
@@ -37,6 +45,14 @@ class WatchProgress {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
dramaTitle: json['dramaTitle']?.toString(),
dramaCover: json['dramaCover']?.toString(),
episodeNumber: json['episodeNumber'] == null
? null
: jsonToInt(json['episodeNumber']),
totalEpisodes: json['totalEpisodes'] == null
? null
: jsonToInt(json['totalEpisodes']),
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
@@ -48,6 +64,10 @@ class WatchProgress {
Map<String, dynamic> toJson() => {
'dramaId': dramaId,
'episodeId': episodeId,
if (dramaTitle != null) 'dramaTitle': dramaTitle,
if (dramaCover != null) 'dramaCover': dramaCover,
if (episodeNumber != null) 'episodeNumber': episodeNumber,
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'finished': finished,
+21 -1
View File
@@ -19,8 +19,10 @@ class WatchRecord {
String get dramaId => progress.dramaId;
/// 详情中按 episodeId 定位集号;缺失时返回 0。
/// 优先使用分页接口集号,其次从已加载详情定位。
int get episodeNumber {
final pageEpisode = progress.episodeNumber;
if (pageEpisode != null && pageEpisode > 0) return pageEpisode;
final episodes = drama?.episodes ?? const <Episode>[];
for (var index = 0; index < episodes.length; index++) {
if (episodes[index].id == progress.episodeId) return index + 1;
@@ -28,6 +30,24 @@ class WatchRecord {
return 0;
}
/// 服务端分页标题优先,详情标题作为回退。
String get displayTitle {
final pageTitle = progress.dramaTitle?.trim() ?? '';
return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? '';
}
/// 服务端分页封面优先,详情封面作为回退。
String get displayCover {
final pageCover = progress.dramaCover?.trim() ?? '';
return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? '';
}
/// 分页接口已带展示字段时无需等待详情加载。
bool get hasDisplayMetadata =>
displayTitle.isNotEmpty ||
progress.dramaCover?.trim().isNotEmpty == true ||
(progress.episodeNumber ?? 0) > 0;
/// 生成卡片加载中、成功或失败后的新状态。
WatchRecord copyWith({
Drama? drama,
+4
View File
@@ -1,8 +1,11 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import 'api_exception.dart';
import 'api_interceptors.dart';
import 'network_log_interceptor.dart';
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
export 'api_exception.dart';
@@ -39,6 +42,7 @@ class ApiClient {
deviceIdReader: deviceIdReader,
),
const EnvelopeInterceptor(),
NetworkLogInterceptor(),
]);
if (adapter != null) {
_dio.httpClientAdapter = adapter;
@@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'api_exception.dart';
import 'network_log_interceptor.dart';
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
class ApiRequestFlags {
@@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor {
ResponseInterceptorHandler handler,
) {
final options = response.requestOptions;
if (NetworkLogInterceptor.isEnabled) {
options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] =
response.data;
}
final shell = _decodeShell(response.data);
final code = shell['code'] as int? ?? -1;
final message = shell['message'] as String?;
+11 -7
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/page_result.dart';
import 'api_client.dart';
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
@@ -20,13 +21,16 @@ class LibraryApi {
return data == true;
}
Future<List<Drama>> favorites() async {
final data = await _client.get('/app/favorites');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
];
Future<PageResult<Drama>> favorites({
int pageIndex = 1,
int pageSize = 20,
}) async {
final data = await _client.get(
'/app/favorites',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
return PageResult.parse(data, Drama.fromCardJson);
}
Future<void> removeFavorite(String dramaId) async {
@@ -0,0 +1,219 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// 调试构建下输出 Dio 请求、响应和异常详情。
class NetworkLogInterceptor extends Interceptor {
/// 在调试时关闭日志的本地常量开关。
static const bool enabled = true;
/// Envelope 解包前暂存原始响应体,供链尾日志读取。
static const String rawResponseDataExtraKey = 'app.http.rawResponseData';
/// 请求、响应体单条日志最大字符数。
static const int maxBodyLength = 3000;
static const String _truncatedSuffix = '…(truncated)';
static const String _stopwatchKey = 'app.http.stopwatch';
static const String _startedAtKey = 'app.http.startedAt';
static const String _emptyBody = '<empty>';
static const String _redacted = '***';
/// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。
NetworkLogInterceptor({void Function(String)? logger})
: _logger = logger ?? debugPrint;
final void Function(String) _logger;
/// release/profile 下恒为 false,保证零 HTTP 日志输出。
static bool get isEnabled => kDebugMode && enabled;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!isEnabled) {
return handler.next(options);
}
final stopwatch = Stopwatch()..start();
options.extra[_stopwatchKey] = stopwatch;
options.extra[_startedAtKey] = DateTime.now().toIso8601String();
final body = _formatRequestBody(options.data);
final message = StringBuffer()
..writeln('[HTTP] → ${options.method} ${options.uri}')
..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}')
..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}')
..writeln('[HTTP] → body:$body');
_logger(message.toString().trimRight());
handler.next(options);
}
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
if (!isEnabled) {
return handler.next(response);
}
final options = response.requestOptions;
final elapsed = _elapsedMilliseconds(options);
final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data;
final body = _formatResponseBody(
rawBody,
response.headers.value(Headers.contentTypeHeader),
);
final message = StringBuffer()
..writeln(
'[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms '
'${options.method} ${options.uri}',
)
..writeln('[HTTP] ← body:$body');
_logger(message.toString().trimRight());
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (!isEnabled) {
return handler.next(err);
}
final options = err.requestOptions;
final message = StringBuffer()
..writeln(
'[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms '
'${options.uri} ${err.message ?? ''}',
)
..write(_errorBodyLine(err));
_logger(message.toString().trimRight());
handler.next(err);
}
int _elapsedMilliseconds(RequestOptions options) {
final stopwatch = options.extra[_stopwatchKey];
if (stopwatch is Stopwatch) {
return stopwatch.elapsedMilliseconds;
}
return 0;
}
String _errorBodyLine(DioException error) {
final rawBody =
error.requestOptions.extra[rawResponseDataExtraKey] ??
error.response?.data;
if (rawBody == null) {
return '';
}
final body = _formatResponseBody(
rawBody,
error.response?.headers.value(Headers.contentTypeHeader),
);
return '\n[HTTP] ✗ body:$body';
}
String _formatHeaders(Map<String, dynamic> headers) {
if (headers.isEmpty) {
return '{}';
}
final entries = headers.entries.toList()
..sort((left, right) => left.key.compareTo(right.key));
final safe = <String, String>{
for (final entry in entries)
entry.key: _safeHeaderValue(entry.key, entry.value),
};
return _truncate(jsonEncode(safe));
}
String _safeHeaderValue(String name, Object? value) {
final text = value?.toString() ?? '';
if (name.toLowerCase() == 'authorization') {
return _maskAuthorization(text);
}
if (name.toLowerCase() == 'x-device-id') {
return text;
}
return _redacted;
}
String _maskAuthorization(String value) {
if (value.length <= 8) {
return _redacted;
}
return '${value.substring(0, math.min(8, value.length))}...';
}
String _formatRequestBody(Object? body) {
if (body == null) {
return _emptyBody;
}
final json = _tryPrettyJson(body);
if (json != null) {
return _truncate(json);
}
final length = switch (body) {
FormData data => data.length,
List<int> bytes => bytes.length,
_ => utf8.encode(body.toString()).length,
};
return '${body.runtimeType} ($length bytes)';
}
String _formatResponseBody(Object? body, String? contentType) {
if (body == null) {
return _emptyBody;
}
if (body is List<int>) {
final bytes = body is Uint8List ? body : Uint8List.fromList(body);
if (_isBinary(contentType)) {
return '<binary ${contentType ?? 'unknown'}, ${bytes.length} bytes>';
}
final text = utf8.decode(bytes, allowMalformed: true);
return _formatText(text);
}
return _formatText(body.toString());
}
String _formatText(String text) {
if (text.isEmpty) {
return _emptyBody;
}
return _truncate(_tryPrettyJson(text) ?? text);
}
String? _tryPrettyJson(Object? body) {
try {
final value = body is String
? jsonDecode(body)
: body is List<int>
? jsonDecode(utf8.decode(body, allowMalformed: true))
: body;
if (value is! Map && value is! List) {
return null;
}
return const JsonEncoder.withIndent(' ').convert(value);
} catch (_) {
return null;
}
}
bool _isBinary(String? contentType) {
final type = contentType?.toLowerCase() ?? '';
return type.startsWith('image/') ||
type.startsWith('audio/') ||
type.startsWith('video/') ||
type.startsWith('font/') ||
type.contains('application/octet-stream') ||
type.contains('application/pdf') ||
type.contains('application/zip');
}
String _truncate(String value) {
if (value.length <= maxBodyLength) {
return value;
}
final end = maxBodyLength - _truncatedSuffix.length;
return '${value.substring(0, end)}$_truncatedSuffix';
}
}
+9 -8
View File
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import '../models/page_result.dart';
import '../models/watch_progress.dart';
import 'api_client.dart';
@@ -39,17 +40,17 @@ class WatchApi {
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
/// 最近观看分页(服务端已按更新时间倒序)。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
final data = await _client.get(
'/app/watch/recent',
query: {'limit': '$limit'},
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
];
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
return PageResult.parse(data, WatchProgress.fromJson);
}
/// 删除某部剧的观看记录(记录页清单条用)。
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/page_result.dart';
import '../remote/library_api.dart';
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
@@ -8,8 +9,8 @@ abstract interface class FavoriteRepository {
/// 切换收藏,返回切换后的状态(true = 已收藏)。
Future<bool> toggle(String dramaId);
/// 收藏列表(卡片结构与首页一致)。
Future<List<Drama>> list();
/// 收藏分页(卡片结构与首页一致)。
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20});
Future<void> remove(String dramaId);
}
@@ -28,9 +29,9 @@ class HttpFavoriteRepository implements FavoriteRepository {
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
@override
Future<List<Drama>> list() async {
if (!_signedIn) return const [];
return _api.favorites();
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
if (!_signedIn) return const PageResult();
return _api.favorites(pageIndex: pageIndex, pageSize: pageSize);
}
@override
@@ -46,10 +47,10 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
final Set<String> _saved;
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
bool failPage = false;
bool failRemove = false;
int toggleCalls = 0;
int listCalls = 0;
int pageCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
@@ -60,10 +61,18 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
}
@override
Future<List<Drama>> list() async {
listCalls++;
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
pageCalls++;
if (failPage) throw StateError('favorite unavailable');
final filtered = _cards.where((card) => _saved.contains(card.id)).toList();
final offset = (pageIndex - 1) * pageSize;
if (offset >= filtered.length) {
return PageResult(total: filtered.length);
}
return PageResult(
total: filtered.length,
list: filtered.skip(offset).take(pageSize).toList(),
);
}
@override
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import '../models/page_result.dart';
import '../models/watch_progress.dart';
import '../remote/watch_api.dart';
@@ -20,8 +21,11 @@ abstract interface class WatchHistoryRepository {
bool finished = false,
});
/// 最近观看(默认 20 条)。
Future<List<WatchProgress>> recent({int limit = 20});
/// 最近观看分页。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
});
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
@@ -63,9 +67,12 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository {
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (!_signedIn) return const [];
return _api.recent(limit: limit);
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
if (!_signedIn) return const PageResult();
return _api.recent(pageIndex: pageIndex, pageSize: pageSize);
}
@override
@@ -123,12 +130,23 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records.values.take(limit).toList();
final items = _records.values.toList();
final offset = (pageIndex - 1) * pageSize;
if (offset >= items.length) {
return PageResult(total: items.length);
}
return PageResult(
total: items.length,
list: items.skip(offset).take(pageSize).toList(),
);
}
@override
+102 -8
View File
@@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart';
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
static const int defaultPageSize = 20;
final FavoriteRepository _repository;
final int pageSize;
final Set<String> _saved = {};
List<Drama> cards = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
String? error;
String? loadMoreError;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
@@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier {
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
notify();
try {
final list = await _repository.list();
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
if (_disposed || request != _requestSeq) return;
final list = _uniqueCards(page.list);
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
error = 'favoritesError';
if (!_disposed && request == _requestSeq) {
error = 'favoritesError';
}
} finally {
loading = false;
notify();
if (!_disposed && request == _requestSeq) {
loading = false;
notify();
}
}
}
/// 追加下一页;正在刷新、已在加载或无更多时直接忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
notify();
try {
final page = await _repository.page(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (_disposed || request != _requestSeq) return;
final known = cards.map((drama) => drama.id).toSet();
final appended = _uniqueCards(
page.list,
).where((drama) => known.add(drama.id)).toList(growable: false);
cards = [...cards, ...appended];
_saved.addAll(appended.map((drama) => drama.id));
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
if (!_disposed && request == _requestSeq) {
loadMoreError = 'favoritesError';
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
notify();
}
}
}
@@ -52,10 +107,17 @@ class FavoriteController extends ChangeNotifier {
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
final previousCards = cards;
final previousTotal = total;
final previousHasMore = hasMore;
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
} else {
_saved.add(dramaId);
}
@@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier {
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
if (wasSaved) {
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
total += 1;
hasMore = cards.length < total;
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
}
notify();
return true;
} catch (_) {
// 回滚
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
if (wasSaved) {
_saved.add(dramaId);
} else {
@@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier {
_saved.remove(dramaId);
final next = [...cards]..removeAt(index);
cards = next;
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
notify();
try {
await _repository.remove(dramaId);
@@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier {
);
cards = restored;
_saved.add(dramaId);
total += 1;
hasMore = cards.length < total;
notify();
return false;
}
@@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier {
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_requestSeq += 1;
_saved.clear();
cards = const [];
total = 0;
loading = false;
hasMore = false;
loadingMore = false;
loadMoreError = null;
error = null;
_pageIndex = 0;
notify();
}
List<Drama> _uniqueCards(List<Drama> source) {
final seen = <String>{};
return [
for (final drama in source)
if (seen.add(drama.id)) drama,
];
}
@override
void dispose() {
_disposed = true;
@@ -0,0 +1,89 @@
// Author: fruit.com.cn
const Duration _defaultMaxAge = Duration(hours: 12);
/// 将播放位置限制在当前媒体时长内。
Duration clampPlaybackPosition(Duration position, Duration duration) {
if (position <= Duration.zero || duration <= Duration.zero) {
return Duration.zero;
}
if (position >= duration) return duration;
return position;
}
/// 进程内最近播放位置。
class CachedPlaybackPosition {
const CachedPlaybackPosition({
required this.episodeId,
required this.dramaId,
required this.position,
required this.duration,
required this.updatedAt,
});
final String episodeId;
final String dramaId;
final Duration position;
final Duration duration;
final DateTime updatedAt;
}
/// 按集缓存最近播放位置,仅当前进程有效。
class PlaybackPositionCache {
PlaybackPositionCache({
DateTime Function()? now,
this.maxAge = _defaultMaxAge,
}) : _now = now ?? DateTime.now;
final DateTime Function() _now;
final Duration maxAge;
final Map<String, CachedPlaybackPosition> _positions = {};
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
void save({
required String episodeId,
required String dramaId,
required Duration position,
required Duration duration,
}) {
final safePosition = clampPlaybackPosition(position, duration);
if (episodeId.isEmpty || dramaId.isEmpty || safePosition <= Duration.zero) {
return;
}
_positions[episodeId] = CachedPlaybackPosition(
episodeId: episodeId,
dramaId: dramaId,
position: safePosition,
duration: duration,
updatedAt: _now(),
);
}
/// 读取同剧同集且未过期的最近位置。
CachedPlaybackPosition? read(String episodeId, {required String dramaId}) {
final entry = _positions[episodeId];
if (!_isValid(entry, dramaId)) return null;
return entry;
}
/// 读取指定短剧最近更新的有效位置。
CachedPlaybackPosition? latestForDrama(String dramaId) {
CachedPlaybackPosition? latest;
for (final entry in _positions.values) {
if (!_isValid(entry, dramaId)) continue;
if (latest == null || !entry.updatedAt.isBefore(latest.updatedAt)) {
latest = entry;
}
}
return latest;
}
bool _isValid(CachedPlaybackPosition? entry, String dramaId) {
if (entry == null ||
entry.dramaId != dramaId ||
entry.position <= Duration.zero) {
return false;
}
return _now().difference(entry.updatedAt) <= maxAge;
}
}
@@ -0,0 +1,365 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// 原生取帧所需的播放源信息。
class PlaybackThumbnailSource {
const PlaybackThumbnailSource({
this.localPath = '',
this.remoteUrl = '',
this.headers = const {},
});
final String localPath;
final String remoteUrl;
final Map<String, String> headers;
bool get isAvailable => localPath.isNotEmpty || remoteUrl.isNotEmpty;
}
/// 按 episodeId 读取当前可用播放源,确保签名地址刷新后立即生效。
typedef PlaybackThumbnailSourceResolver =
PlaybackThumbnailSource? Function(String episodeId);
/// 传给原生取帧层的单次请求。
class PlaybackThumbnailFrameRequest {
const PlaybackThumbnailFrameRequest({
required this.episodeId,
required this.position,
required this.source,
});
final String episodeId;
final Duration position;
final PlaybackThumbnailSource source;
Map<String, Object> toChannelArguments() => {
'episodeId': episodeId,
'timeMs': position.inMilliseconds,
'localPath': source.localPath,
'url': source.remoteUrl,
'headers': source.headers,
};
}
/// 可注入的原生取帧接口。
abstract interface class PlaybackThumbnailFrameLoader {
Future<ByteData?> load(PlaybackThumbnailFrameRequest request);
void dispose();
}
/// 默认通过平台通道从本地播放器原生层取帧。
class MethodChannelPlaybackThumbnailFrameLoader
implements PlaybackThumbnailFrameLoader {
MethodChannelPlaybackThumbnailFrameLoader({MethodChannel? channel})
: _channel =
channel ?? const MethodChannel('com.stormg.lumadrama/thumbnail');
final MethodChannel _channel;
@override
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async {
final bytes = await _channel.invokeMethod<Uint8List>(
'frame',
request.toChannelArguments(),
);
return bytes == null ? null : ByteData.sublistView(bytes);
}
@override
void dispose() {}
}
/// 每秒一个 bucket,拖过同一秒时复用同一张缩略图。
int playbackThumbnailBucket(Duration position) {
final milliseconds = position.inMilliseconds;
if (milliseconds <= 0) return 0;
return milliseconds ~/ Duration.millisecondsPerSecond;
}
/// 播放器缩略图单飞、LRU 缓存与拖动预取调度。
class PlaybackThumbnailService {
PlaybackThumbnailService({
PlaybackThumbnailFrameLoader? frameLoader,
this.sourceResolver,
this.requestTimeout = const Duration(milliseconds: 1500),
this.prefetchDelay = const Duration(milliseconds: 120),
this.cacheCapacity = defaultCacheCapacity,
}) : _frameLoader =
frameLoader ?? MethodChannelPlaybackThumbnailFrameLoader(),
assert(cacheCapacity > 0);
static const int defaultCacheCapacity = 24;
final PlaybackThumbnailFrameLoader _frameLoader;
final PlaybackThumbnailSourceResolver? sourceResolver;
final Duration requestTimeout;
final Duration prefetchDelay;
final int cacheCapacity;
final LinkedHashMap<String, ByteData> _cache = LinkedHashMap();
final LinkedHashMap<String, bool> _unavailable = LinkedHashMap();
_ThumbnailRequest? _inFlight;
Future<ByteData?>? _inFlightFuture;
_ThumbnailRequest? _queued;
Timer? _prefetchTimer;
String? _wantedKey;
String? _latestEpisodeId;
int? _latestBucket;
int _prefetchDirection = 1;
bool _disposed = false;
@visibleForTesting
int get cacheSize => _cache.length;
/// 取指定剧集、时间点的缩略图。失败或超时统一返回 null。
Future<ByteData?> thumbnail(String episodeId, Duration position) {
if (_disposed) return Future<ByteData?>.value();
final bucket = playbackThumbnailBucket(position);
final key = _key(episodeId, bucket);
_wantedKey = key;
_noteRequestPosition(episodeId, bucket);
_prefetchTimer?.cancel();
_prefetchTimer = null;
_dropQueued();
final cached = _readCache(key);
if (cached != null) {
_schedulePrefetch(key);
return Future<ByteData?>.value(cached);
}
if (_inFlight?.key == key && _inFlightFuture != null) {
_schedulePrefetch(key);
return _inFlightFuture!;
}
if (_unavailable.containsKey(key)) {
_promoteUnavailable(key);
_schedulePrefetch(key);
return Future<ByteData?>.value();
}
final request = _ThumbnailRequest(
key: key,
episodeId: episodeId,
bucket: bucket,
);
_schedulePrefetch(key);
if (_inFlight == null) return _start(request);
return _queue(request);
}
/// 松手或离开拖动后调用;只取消尚未开始的请求。
void cancelPending() {
_wantedKey = null;
_prefetchTimer?.cancel();
_prefetchTimer = null;
_dropQueued();
}
/// 页面销毁时清空帧缓存和所有待执行请求。
void clear() {
cancelPending();
_cache.clear();
_unavailable.clear();
_latestEpisodeId = null;
_latestBucket = null;
_prefetchDirection = 1;
}
void dispose() {
if (_disposed) return;
_disposed = true;
clear();
_frameLoader.dispose();
}
Future<ByteData?> _start(_ThumbnailRequest request) {
final completer = Completer<ByteData?>();
_inFlight = request;
_inFlightFuture = completer.future;
unawaited(_run(request, completer));
return completer.future;
}
Future<ByteData?> _queue(_ThumbnailRequest request) {
final completer = Completer<ByteData?>();
_queued = request.withCompleter(completer);
return completer.future;
}
Future<void> _run(
_ThumbnailRequest request,
Completer<ByteData?> completer,
) async {
ByteData? frame;
try {
frame = await _loadFrame(request);
} catch (_) {
frame = null;
}
if (frame == null) {
_putUnavailable(request.key);
} else {
_putCache(request.key, frame);
}
final deliver = _wantedKey == request.key;
_inFlight = null;
_inFlightFuture = null;
if (!completer.isCompleted) {
completer.complete(deliver ? frame : null);
}
_drainQueued();
}
Future<ByteData?> _loadFrame(_ThumbnailRequest request) {
PlaybackThumbnailSource? source;
try {
source = sourceResolver?.call(request.episodeId);
} catch (_) {
return Future<ByteData?>.value();
}
if (source == null || !source.isAvailable) {
return Future<ByteData?>.value();
}
final frame = PlaybackThumbnailFrameRequest(
episodeId: request.episodeId,
position: Duration(seconds: request.bucket),
source: source,
);
final result = Completer<ByteData?>();
late final Timer timeout;
void finish(ByteData? value) {
if (result.isCompleted) return;
timeout.cancel();
result.complete(value);
}
timeout = Timer(requestTimeout, () => finish(null));
try {
_frameLoader
.load(frame)
.then(finish, onError: (Object _, StackTrace _) => finish(null));
} catch (_) {
finish(null);
}
return result.future;
}
void _drainQueued() {
if (_disposed || _inFlight != null) return;
final queued = _queued;
_queued = null;
if (queued == null || queued.key != _wantedKey) {
queued?.completer.complete(null);
return;
}
_inFlight = queued;
_inFlightFuture = queued.completer.future;
unawaited(_run(queued, queued.completer));
}
void _dropQueued() {
final queued = _queued;
_queued = null;
if (queued != null && !queued.completer.isCompleted) {
queued.completer.complete(null);
}
}
void _noteRequestPosition(String episodeId, int bucket) {
if (_latestEpisodeId == episodeId && _latestBucket != null) {
if (bucket < _latestBucket!) _prefetchDirection = -1;
if (bucket > _latestBucket!) _prefetchDirection = 1;
}
_latestEpisodeId = episodeId;
_latestBucket = bucket;
}
void _schedulePrefetch(String currentKey) {
_prefetchTimer?.cancel();
_prefetchTimer = Timer(prefetchDelay, () {
_prefetchTimer = null;
if (_disposed || _wantedKey != currentKey) return;
final episodeId = _latestEpisodeId;
final bucket = _latestBucket;
if (episodeId == null || bucket == null) return;
final neighbor = bucket + _prefetchDirection;
if (neighbor < 0) return;
final key = _key(episodeId, neighbor);
if (_cache.containsKey(key) ||
_unavailable.containsKey(key) ||
_inFlight?.key == key ||
_queued != null) {
return;
}
final request = _ThumbnailRequest(
key: key,
episodeId: episodeId,
bucket: neighbor,
);
if (_inFlight == null) {
unawaited(_start(request));
} else {
_queue(request);
}
});
}
ByteData? _readCache(String key) {
final value = _cache.remove(key);
if (value == null) return null;
_cache[key] = value;
return value;
}
void _putCache(String key, ByteData value) {
_unavailable.remove(key);
_cache.remove(key);
_cache[key] = value;
while (_cache.length > cacheCapacity) {
_cache.remove(_cache.keys.first);
}
}
void _putUnavailable(String key) {
_unavailable.remove(key);
_unavailable[key] = true;
while (_unavailable.length > cacheCapacity) {
_unavailable.remove(_unavailable.keys.first);
}
}
void _promoteUnavailable(String key) {
if (_unavailable.remove(key) == null) return;
_unavailable[key] = true;
}
String _key(String episodeId, int bucket) => '$episodeId:$bucket';
}
class _ThumbnailRequest {
_ThumbnailRequest({
required this.key,
required this.episodeId,
required this.bucket,
Completer<ByteData?>? completer,
}) : completer = completer ?? Completer<ByteData?>();
final String key;
final String episodeId;
final int bucket;
final Completer<ByteData?> completer;
_ThumbnailRequest withCompleter(Completer<ByteData?> value) =>
_ThumbnailRequest(
key: key,
episodeId: episodeId,
bucket: bucket,
completer: value,
);
}
@@ -0,0 +1,128 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
const int spriteThumbnailDecodeMaxDimension = 2048;
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
class SpriteThumbnailForbiddenException implements Exception {
const SpriteThumbnailForbiddenException();
}
/// 已解码 sprite 与原图尺寸;cue 坐标会按解码缩放比例映射。
class SpriteThumbnailDecodedImage {
const SpriteThumbnailDecodedImage({
required this.image,
required this.originalWidth,
required this.originalHeight,
});
final ui.Image image;
final int originalWidth;
final int originalHeight;
}
/// 可注入的资源加载接口,测试无需真实网络或图片。
abstract interface class SpriteThumbnailAssetLoader {
Future<String?> loadVtt(Uri uri);
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri);
}
/// 默认通过项目现有 Dio 拉取 VTT 和 sprite 图片。
class DioSpriteThumbnailAssetLoader implements SpriteThumbnailAssetLoader {
DioSpriteThumbnailAssetLoader({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: AppConfig.requestTimeout,
receiveTimeout: AppConfig.requestTimeout,
validateStatus: (_) => true,
),
);
final Dio _dio;
@override
Future<String?> loadVtt(Uri uri) async {
final response = await _request(uri);
if (response?.statusCode == 403) {
throw const SpriteThumbnailForbiddenException();
}
if (response == null || !_isSuccess(response.statusCode)) return null;
final data = response.data;
if (data is! List<int>) return null;
return utf8.decode(data, allowMalformed: true);
}
@override
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
final response = await _request(uri);
if (response == null || !_isSuccess(response.statusCode)) return null;
final data = response.data;
if (data is! List<int>) return null;
return _decode(Uint8List.fromList(data));
}
Future<Response<List<int>>?> _request(Uri uri) async {
try {
return await _dio.get<List<int>>(
uri.toString(),
options: Options(responseType: ResponseType.bytes),
);
} catch (_) {
return null;
}
}
bool _isSuccess(int? status) =>
status != null && status >= 200 && status < 300;
Future<SpriteThumbnailDecodedImage?> _decode(Uint8List bytes) async {
ui.ImmutableBuffer? buffer;
ui.ImageDescriptor? descriptor;
ui.Codec? codec;
try {
buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
descriptor = await ui.ImageDescriptor.encoded(buffer);
final originalWidth = descriptor.width;
final originalHeight = descriptor.height;
final target = _targetSize(originalWidth, originalHeight);
codec = await descriptor.instantiateCodec(
targetWidth: target.$1,
targetHeight: target.$2,
);
final frame = await codec.getNextFrame();
return SpriteThumbnailDecodedImage(
image: frame.image,
originalWidth: originalWidth,
originalHeight: originalHeight,
);
} catch (_) {
return null;
} finally {
codec?.dispose();
descriptor?.dispose();
buffer?.dispose();
}
}
(int?, int?) _targetSize(int width, int height) {
final longest = width > height ? width : height;
if (longest <= spriteThumbnailDecodeMaxDimension) {
return (null, null);
}
final scale = spriteThumbnailDecodeMaxDimension / longest;
return (
(width * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension),
(height * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension),
);
}
}
@@ -0,0 +1,461 @@
// Author: fruit.com.cn
import 'dart:collection';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import '../../config/app_config.dart';
import 'sprite_thumbnail_asset_loader.dart';
const Duration spriteVttCacheTtl = Duration(minutes: 10);
const int spriteImageCacheCapacity = 2;
/// 单个 VTT cue 对应的 sprite 裁剪区域。
class SpriteThumbnailCue {
const SpriteThumbnailCue({
required this.start,
required this.end,
required this.spriteFile,
required this.sourceRect,
});
final Duration start;
final Duration end;
final String spriteFile;
final ui.Rect sourceRect;
}
/// 已解析的 VTT 时间轴。
class SpriteThumbnailAxis {
const SpriteThumbnailAxis({required this.vttUrl, required this.cues});
final String vttUrl;
final List<SpriteThumbnailCue> cues;
factory SpriteThumbnailAxis.empty(String vttUrl) =>
SpriteThumbnailAxis(vttUrl: vttUrl, cues: const []);
Uri resolveSpriteUri(String spriteFile) =>
Uri.parse(vttUrl).resolve(spriteFile);
}
/// 可直接交给 RawImage.sourceRect 的裁剪帧。
class SpriteThumbnailFrame {
const SpriteThumbnailFrame({
required this.image,
required this.sourceRect,
required this.spriteUrl,
required this.cue,
});
final ui.Image image;
final ui.Rect sourceRect;
final String spriteUrl;
final SpriteThumbnailCue cue;
}
/// 预览卡片当前应展示的回退阶段。
enum ScrubPreviewStage { sprite, waitingForAxis, localFrame, cover }
/// 预览回退链的纯判定函数。
ScrubPreviewStage decideScrubPreviewStage({
required bool hasThumbnailsUrl,
required bool hasSpriteFrame,
required bool hasAxis,
required bool hasLocalFrame,
}) {
if (hasSpriteFrame) return ScrubPreviewStage.sprite;
if (hasThumbnailsUrl && !hasAxis) {
return ScrubPreviewStage.waitingForAxis;
}
if (hasLocalFrame) return ScrubPreviewStage.localFrame;
return ScrubPreviewStage.cover;
}
/// 解析 WebVTT;任意异常都收敛为空轴。
SpriteThumbnailAxis parseSpriteThumbnailVtt(
String source, {
required String vttUrl,
}) {
final cues = <SpriteThumbnailCue>[];
try {
for (final block
in source.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) {
final cue = _parseCueBlock(block);
if (cue != null) cues.add(cue);
}
} catch (_) {
return SpriteThumbnailAxis.empty(vttUrl);
}
cues.sort((left, right) => left.start.compareTo(right.start));
return SpriteThumbnailAxis(vttUrl: vttUrl, cues: cues);
}
/// 按 position 二分选择 cue;边界外选择最近 cue。
SpriteThumbnailCue? selectSpriteThumbnailCue(
List<SpriteThumbnailCue> cues,
Duration position,
) {
if (cues.isEmpty) return null;
var low = 0;
var high = cues.length;
while (low < high) {
final middle = (low + high) >> 1;
if (cues[middle].start <= position) {
low = middle + 1;
} else {
high = middle;
}
}
if (low == 0) return cues.first;
final previous = cues[low - 1];
if (low == cues.length || position <= previous.end) return previous;
final next = cues[low];
final previousDistance = position - previous.end;
final nextDistance = next.start - position;
return previousDistance <= nextDistance ? previous : next;
}
/// 只读缓存接口,便于 widget 测试注入。
abstract interface class SpriteThumbnailAxisSource implements Listenable {
SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position);
bool hasAxis(String? thumbnailsUrl);
}
/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。
class SpriteThumbnailAxisService extends ChangeNotifier
implements SpriteThumbnailAxisSource {
SpriteThumbnailAxisService({
SpriteThumbnailAssetLoader? loader,
this.cacheTtl = spriteVttCacheTtl,
DateTime Function()? now,
}) : _loader = loader ?? DioSpriteThumbnailAssetLoader(),
_now = now ?? DateTime.now;
final SpriteThumbnailAssetLoader _loader;
final Duration cacheTtl;
final DateTime Function() _now;
final Map<String, _CachedAxis> _axisCache = {};
final Map<String, Future<SpriteThumbnailAxis>> _axisRequests = {};
final LinkedHashMap<String, _CachedSprite> _spriteCache = LinkedHashMap();
final Map<String, Future<_CachedSprite?>> _spriteRequests = {};
final Set<String> _failedSprites = {};
String? _activeAxisUrl;
int _axisGeneration = 0;
bool _disposed = false;
@visibleForTesting
int get spriteCacheSize => _spriteCache.length;
@visibleForTesting
List<String> get spriteCacheKeys => _spriteCache.keys.toList();
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
final url = _normalizeUrl(thumbnailsUrl);
if (_disposed || url == null) return;
_activateAxis(url);
final axis = await _loadAxis(url);
if (_disposed || _activeAxisUrl != url) return;
for (final cue in _prefetchCues(axis, position)) {
await _ensureSprite(axis, cue);
}
}
@override
bool hasAxis(String? thumbnailsUrl) {
final url = _normalizeUrl(thumbnailsUrl);
if (url == null || url != _activeAxisUrl) return false;
return _readAxis(url) != null;
}
@override
SpriteThumbnailFrame? cachedFrameAt(
String? thumbnailsUrl,
Duration position,
) {
final url = _normalizeUrl(thumbnailsUrl);
if (url == null || url != _activeAxisUrl) return null;
final axis = _readAxis(url);
if (axis == null) return null;
final cue = selectSpriteThumbnailCue(axis.cues, position);
if (cue == null) return null;
final cached = _readSprite(cue.spriteFile);
if (cached == null) return null;
final sourceRect = _scaledSourceRect(cached, cue.sourceRect);
if (sourceRect == null) return null;
return SpriteThumbnailFrame(
image: cached.decoded.image,
sourceRect: sourceRect,
spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(),
cue: cue,
);
}
/// 页面销毁时释放全部 VTT 与 ui.Image 内存。
@override
void dispose() {
if (_disposed) return;
_disposed = true;
_clearAll();
super.dispose();
}
void _activateAxis(String url) {
if (_activeAxisUrl == url) return;
_activeAxisUrl = url;
_axisGeneration++;
_clearSprites();
}
Future<SpriteThumbnailAxis> _loadAxis(String url) {
final cached = _readAxis(url);
if (cached != null) return Future.value(cached);
final existing = _axisRequests[url];
if (existing != null) return existing;
final request = _fetchAxis(url);
_axisRequests[url] = request;
return request.whenComplete(() {
_axisRequests.remove(url);
});
}
Future<SpriteThumbnailAxis> _fetchAxis(String url) async {
for (var attempt = 0; attempt < 2; attempt++) {
try {
final text = await _loader.loadVtt(Uri.parse(url));
final axis = text == null
? SpriteThumbnailAxis.empty(url)
: parseSpriteThumbnailVtt(text, vttUrl: url);
if (_disposed) return SpriteThumbnailAxis.empty(url);
_cacheAxis(axis);
notifyListeners();
return axis;
} on SpriteThumbnailForbiddenException {
if (_disposed) return SpriteThumbnailAxis.empty(url);
_axisCache.remove(url);
if (_activeAxisUrl == url) _clearSprites();
} catch (_) {
break;
}
}
final empty = SpriteThumbnailAxis.empty(url);
if (_disposed) return empty;
_cacheAxis(empty);
notifyListeners();
return empty;
}
void _cacheAxis(SpriteThumbnailAxis axis) {
_axisCache[axis.vttUrl] = _CachedAxis(
axis: axis,
expiresAt: _now().add(cacheTtl),
);
}
SpriteThumbnailAxis? _readAxis(String url) {
final cached = _axisCache[url];
if (cached == null) return null;
if (cached.expiresAt.isAfter(_now())) return cached.axis;
_axisCache.remove(url);
return null;
}
List<SpriteThumbnailCue> _prefetchCues(
SpriteThumbnailAxis axis,
Duration position,
) {
final current = selectSpriteThumbnailCue(axis.cues, position);
if (current == null) return const [];
final index = axis.cues.indexOf(current);
final candidates = <SpriteThumbnailCue>[current];
for (final direction in const [1, -1]) {
for (var step = 1; step < axis.cues.length; step++) {
final neighborIndex = index + direction * step;
if (neighborIndex < 0 || neighborIndex >= axis.cues.length) break;
final neighbor = axis.cues[neighborIndex];
if (neighbor.spriteFile != current.spriteFile) {
candidates.add(neighbor);
break;
}
}
if (candidates.length > 1) break;
}
return candidates;
}
Future<_CachedSprite?> _ensureSprite(
SpriteThumbnailAxis axis,
SpriteThumbnailCue cue,
) {
final cached = _readSprite(cue.spriteFile);
if (cached != null) return Future.value(cached);
if (_failedSprites.contains(cue.spriteFile)) return Future.value();
final existing = _spriteRequests[cue.spriteFile];
if (existing != null) return existing;
final generation = _axisGeneration;
final request = _loadSprite(axis, cue, generation);
_spriteRequests[cue.spriteFile] = request;
return request.whenComplete(() {
_spriteRequests.remove(cue.spriteFile);
});
}
Future<_CachedSprite?> _loadSprite(
SpriteThumbnailAxis axis,
SpriteThumbnailCue cue,
int generation,
) async {
SpriteThumbnailDecodedImage? decoded;
try {
decoded = await _loader.loadSprite(axis.resolveSpriteUri(cue.spriteFile));
} catch (_) {
decoded = null;
}
if (decoded == null) {
_failedSprites.add(cue.spriteFile);
if (!_disposed) notifyListeners();
return null;
}
if (_disposed || generation != _axisGeneration) {
decoded.image.dispose();
return null;
}
final cached = _CachedSprite(decoded);
_putSprite(cue.spriteFile, cached);
notifyListeners();
return cached;
}
_CachedSprite? _readSprite(String key) {
final value = _spriteCache.remove(key);
if (value == null) return null;
_spriteCache[key] = value;
return value;
}
void _putSprite(String key, _CachedSprite value) {
_spriteCache.remove(key)?.decoded.image.dispose();
_spriteCache[key] = value;
while (_spriteCache.length > spriteImageCacheCapacity) {
final oldest = _spriteCache.remove(_spriteCache.keys.first);
oldest?.decoded.image.dispose();
}
}
ui.Rect? _scaledSourceRect(_CachedSprite cached, ui.Rect source) {
final scaleX = cached.decoded.image.width / cached.decoded.originalWidth;
final scaleY = cached.decoded.image.height / cached.decoded.originalHeight;
final scaled =
ui.Rect.fromLTWH(
source.left * scaleX,
source.top * scaleY,
source.width * scaleX,
source.height * scaleY,
).intersect(
ui.Rect.fromLTWH(
0,
0,
cached.decoded.image.width.toDouble(),
cached.decoded.image.height.toDouble(),
),
);
return scaled.isEmpty ? null : scaled;
}
void _clearSprites() {
for (final cached in _spriteCache.values) {
cached.decoded.image.dispose();
}
_spriteCache.clear();
_spriteRequests.clear();
_failedSprites.clear();
}
void _clearAll() {
_axisGeneration++;
_activeAxisUrl = null;
_axisCache.clear();
_axisRequests.clear();
_clearSprites();
}
String? _normalizeUrl(String? url) {
final trimmed = url?.trim() ?? '';
if (trimmed.isEmpty) return null;
return AppConfig.mediaUrl(trimmed);
}
}
final RegExp _timingPattern = RegExp(
r'^(\d{2,}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2,}:\d{2}:\d{2}\.\d{3})',
);
final RegExp _cueLinkPattern = RegExp(r'^(\S+?)#xywh=(.+)$');
final RegExp _xywhPattern = RegExp(r'^(\d+),(\d+),(\d+),(\d+)$');
SpriteThumbnailCue? _parseCueBlock(String block) {
final lines = block
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.toList();
final timingIndex = lines.indexWhere(_timingPattern.hasMatch);
if (timingIndex < 0 || timingIndex + 1 >= lines.length) return null;
final timing = _timingPattern.firstMatch(lines[timingIndex]);
if (timing == null) return null;
final link = _cueLinkPattern.firstMatch(lines[timingIndex + 1]);
if (link == null) return null;
final start = _parseTimestamp(timing.group(1)!);
final end = _parseTimestamp(timing.group(2)!);
if (start == null || end == null || end <= start) return null;
final segments = Uri.tryParse(link.group(1)!)?.pathSegments;
final file = segments == null || segments.isEmpty ? null : segments.last;
final xywh = _xywhPattern.firstMatch(link.group(2)!);
if (file == null || file.isEmpty || xywh == null) return null;
final rect = ui.Rect.fromLTWH(
double.parse(xywh.group(1)!),
double.parse(xywh.group(2)!),
double.parse(xywh.group(3)!),
double.parse(xywh.group(4)!),
);
if (rect.isEmpty) return null;
return SpriteThumbnailCue(
start: start,
end: end,
spriteFile: file,
sourceRect: rect,
);
}
Duration? _parseTimestamp(String value) {
final parts = value.split(':');
if (parts.length != 3) return null;
final secondsParts = parts[2].split('.');
if (secondsParts.length != 2) return null;
final hours = int.tryParse(parts[0]);
final minutes = int.tryParse(parts[1]);
final seconds = int.tryParse(secondsParts[0]);
final milliseconds = int.tryParse(secondsParts[1]);
if ([hours, minutes, seconds, milliseconds].contains(null)) return null;
return Duration(
hours: hours!,
minutes: minutes!,
seconds: seconds!,
milliseconds: milliseconds!,
);
}
class _CachedAxis {
const _CachedAxis({required this.axis, required this.expiresAt});
final SpriteThumbnailAxis axis;
final DateTime expiresAt;
}
class _CachedSprite {
const _CachedSprite(this.decoded);
final SpriteThumbnailDecodedImage decoded;
}
@@ -61,6 +61,9 @@ class VideoPlayerPort implements PlayerPort {
if (!_disposed && controller.value.isInitialized) await controller.pause();
}
/// 跳转到指定播放位置。
Future<void> seekTo(Duration position) => controller.seekTo(position);
@override
Future<void> dispose() async {
if (_disposed) return;
@@ -11,41 +11,53 @@ import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
int pageSize = defaultPageSize,
}) => WatchRecordsController._(history, catalog, pageSize);
WatchRecordsController._(this._history, this._catalog, this.limit);
WatchRecordsController._(this._history, this._catalog, this.pageSize);
static const int defaultLimit = 20;
static const int defaultPageSize = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final int pageSize;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
bool clearing = false;
String? error;
String? loadMoreError;
String? actionError;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
final page = await _history.recent(pageIndex: 1, pageSize: pageSize);
if (_disposed || request != _requestSeq) return;
records = _uniqueRecords(page.list);
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && records.length < total;
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (failure, stackTrace) {
@@ -57,17 +69,54 @@ class WatchRecordsController extends ChangeNotifier {
stackTrace: stackTrace,
label: '[WatchRecordsController] recent failed',
);
if (!_disposed) {
if (!_disposed && request == _requestSeq) {
error = failure is ApiException ? failure.message : 'progressError';
}
} finally {
if (!_disposed) {
if (!_disposed && request == _requestSeq) {
loading = false;
_notify();
}
}
}
/// 追加下一页;刷新、加载中或无更多时忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
_notify();
try {
final page = await _history.recent(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (_disposed || request != _requestSeq) return;
final known = records.map((record) => record.dramaId).toSet();
final appended = _uniqueRecords(
page.list,
).where((record) => known.add(record.dramaId)).toList(growable: false);
records = [...records, ...appended];
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && records.length < total;
_notify();
await _hydrate(appended.map((record) => record.dramaId).toList());
} catch (failure) {
if (!_disposed && request == _requestSeq) {
loadMoreError = failure is ApiException
? failure.message
: 'progressError';
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
@@ -81,12 +130,23 @@ class WatchRecordsController extends ChangeNotifier {
_notify();
}
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
Future<Drama?> detailForOpen(String dramaId) async {
final cached = _detailCache[dramaId];
if (cached != null) return cached;
await _loadDetail(dramaId);
_notify();
return _detailCache[dramaId];
}
/// 删除单条记录,先更新 UI,失败按原位置回滚。
Future<bool> remove(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
total = total > 0 ? total - 1 : 0;
hasMore = records.length < total;
actionError = null;
_notify();
try {
@@ -96,6 +156,8 @@ class WatchRecordsController extends ChangeNotifier {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
total += 1;
hasMore = records.length < total;
actionError = 'recordDeleteFailed';
return false;
} finally {
@@ -106,8 +168,15 @@ class WatchRecordsController extends ChangeNotifier {
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
_requestSeq += 1;
final previous = records;
final previousTotal = total;
final previousPageIndex = _pageIndex;
records = const [];
total = 0;
hasMore = false;
_pageIndex = 0;
loadingMore = false;
clearing = true;
actionError = null;
_notify();
@@ -116,6 +185,9 @@ class WatchRecordsController extends ChangeNotifier {
return true;
} catch (_) {
records = previous;
total = previousTotal;
_pageIndex = previousPageIndex;
hasMore = records.length < total;
actionError = 'recordClearFailed';
return false;
} finally {
@@ -138,11 +210,12 @@ class WatchRecordsController extends ChangeNotifier {
}
Future<void> _hydrate(List<String> dramaIds) async {
for (final id in dramaIds) {
final missingTitles = dramaIds.where(_hasMissingTitle).toSet().toList();
for (final id in missingTitles) {
if (_detailCache.containsKey(id)) await _loadDetail(id);
}
final queue = [
for (final id in dramaIds)
for (final id in missingTitles)
if (!_detailCache.containsKey(id)) id,
];
final workerCount = queue.length < detailConcurrency
@@ -159,9 +232,15 @@ class WatchRecordsController extends ChangeNotifier {
]);
}
Future<void> _loadDetail(String dramaId) async {
bool _hasMissingTitle(String dramaId) {
final index = _recordIndex(dramaId);
if (index < 0) return;
if (index < 0) return false;
return (records[index].progress.dramaTitle?.trim() ?? '').isEmpty;
}
Future<Drama?> _loadDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return null;
final cached = _detailCache[dramaId];
if (cached != null) {
_replaceAt(
@@ -172,14 +251,14 @@ class WatchRecordsController extends ChangeNotifier {
detailFailed: false,
),
);
return;
return cached;
}
try {
final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return;
if (_disposed) return null;
_detailCache[dramaId] = detail;
final current = _recordIndex(dramaId);
if (current < 0) return;
if (current < 0) return detail;
_replaceAt(
current,
records[current].copyWith(
@@ -188,6 +267,7 @@ class WatchRecordsController extends ChangeNotifier {
detailFailed: false,
),
);
return detail;
} catch (failure, stackTrace) {
debugPrint(
'[WatchRecordsController] detail failed dramaId=$dramaId '
@@ -197,13 +277,14 @@ class WatchRecordsController extends ChangeNotifier {
stackTrace: stackTrace,
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
);
if (_disposed) return;
if (_disposed) return null;
final current = _recordIndex(dramaId);
if (current < 0) return;
if (current < 0) return null;
_replaceAt(
current,
records[current].copyWith(detailLoading: false, detailFailed: true),
);
return null;
}
}
+86
View File
@@ -0,0 +1,86 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 网络分页列表底部状态:加载、重试、手动加载与无更多。
class PagedListFooter extends StatelessWidget {
const PagedListFooter({
super.key,
required this.loading,
required this.hasMore,
required this.hasItems,
required this.onRetry,
this.errorMessage,
});
final bool loading;
final bool hasMore;
final bool hasItems;
final String? errorMessage;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (errorMessage != null) {
return Padding(
key: const Key('paged-list-error'),
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
Text(
errorMessage!,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
TextButton(
key: const Key('paged-list-retry'),
onPressed: () => unawaited(onRetry()),
child: Text(l10n.retry),
),
],
),
);
}
if (loading) {
return const SizedBox(
key: Key('paged-list-loading'),
height: 56,
child: Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.blue,
),
),
),
);
}
if (hasMore) {
return Center(
child: TextButton(
key: const Key('paged-list-load-more'),
onPressed: () => unawaited(onRetry()),
child: Text(l10n.loadMore),
),
);
}
if (hasItems) {
return Padding(
key: const Key('paged-list-no-more'),
padding: const EdgeInsets.symmetric(vertical: 14),
child: Text(
l10n.noMore,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
);
}
return const SizedBox(height: 16);
}
}
@@ -4,6 +4,7 @@ import '../../core/data/models/drama.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import 'drama_detail_logic.dart';
class DramaDetailBinding {
@@ -12,10 +13,12 @@ class DramaDetailBinding {
required WatchHistoryRepository history,
required PlaybackRepository playback,
required FavoriteController favorites,
required PlaybackPositionCache positionCache,
}) => DramaDetailLogic(
drama: drama,
history: history,
playback: playback,
favorites: favorites,
positionCache: positionCache,
);
}
@@ -11,6 +11,7 @@ import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/wallet_controller.dart';
import '../player/player_view.dart';
@@ -20,6 +21,7 @@ class DramaDetailLogic extends ChangeNotifier {
required this.history,
required this.playback,
required this.favorites,
required this.positionCache,
});
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
@@ -27,6 +29,7 @@ class DramaDetailLogic extends ChangeNotifier {
final WatchHistoryRepository history;
final PlaybackRepository playback;
final FavoriteController favorites;
final PlaybackPositionCache positionCache;
bool loading = false;
bool failed = false;
@@ -71,6 +74,23 @@ class DramaDetailLogic extends ChangeNotifier {
}
Future<void> loadContinue() async {
final cached = positionCache.latestForDrama(drama.id);
if (cached != null) {
final index = _indexOf(cached.episodeId);
if (index != null) {
progress = WatchProgress(
dramaId: cached.dramaId,
episodeId: cached.episodeId,
position: cached.position,
duration: cached.duration,
updatedAt: cached.updatedAt,
);
continueIndex = index;
progressError = null;
notify();
return;
}
}
try {
final record = await history.progressFor(drama.id);
if (_disposed) return;
@@ -12,6 +12,7 @@ import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
@@ -64,6 +65,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
favorites: context.read<FavoriteController>(),
positionCache: context.read<PlaybackPositionCache>(),
);
_load();
}
+61 -12
View File
@@ -16,6 +16,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
@@ -33,8 +34,12 @@ const double _removeIconSize = 20;
const double _statsFontSize = 12;
const double _metaFontSize = 11;
const double _titleFontSize = 15;
const double _synopsisFontSize = 12;
const double _synopsisLineHeight = 1.15;
const double _synopsisGap = 4;
const int _maxTags = 2;
const double _emptyListHeight = 360;
const double _loadMoreThreshold = 240;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
@@ -156,6 +161,15 @@ class _FavoritesBody extends StatelessWidget {
onRemove: () => _remove(context, drama.id),
),
],
PagedListFooter(
loading: favorites.loadingMore,
hasMore: favorites.hasMore,
hasItems: favorites.cards.isNotEmpty,
errorMessage: favorites.loadMoreError == null
? null
: errorText(l10n, favorites.loadMoreError, l10n.favoritesError),
onRetry: favorites.loadMore,
),
],
);
}
@@ -164,26 +178,42 @@ class _FavoritesBody extends StatelessWidget {
FavoriteController favorites, {
required List<Widget> children,
}) {
return AppPullToRefresh(
onRefresh: favorites.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: favorites.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
),
children: children,
),
children: children,
),
);
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(favorites.loadMore());
}
return false;
}
Widget _summary(AppLocalizations l10n) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.favoritesCount(favorites.cards.length),
l10n.favoritesCount(favorites.total),
key: const Key('favorites-count'),
style: const TextStyle(
color: AppColors.muted,
@@ -225,6 +255,7 @@ class _FavoriteCard extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final tags = _tags();
final synopsis = drama.synopsis.trim();
return Container(
key: ValueKey('favorite-card-${drama.id}'),
decoration: BoxDecoration(
@@ -318,7 +349,25 @@ class _FavoriteCard extends StatelessWidget {
),
),
),
const Spacer(),
if (synopsis.isNotEmpty) ...[
const SizedBox(height: _synopsisGap),
Flexible(
child: Text(
synopsis,
key: ValueKey(
'favorite-synopsis-${drama.id}',
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: _synopsisFontSize,
height: _synopsisLineHeight,
),
),
),
] else
const Spacer(),
Row(
children: [
const Icon(
+3 -2
View File
@@ -22,13 +22,14 @@ class MineLogic extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
/// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。
/// 最近观看:取分页首屏,剧名封面仍由目录卡片按 id 映射。
Future<void> refreshRecent() async {
recentLoading = true;
recentError = null;
notify();
try {
recent = await history.recent(limit: 20);
final page = await history.recent(pageIndex: 1, pageSize: 20);
recent = page.list;
} catch (_) {
recentError = 'progressError';
} finally {
+3
View File
@@ -4,6 +4,7 @@ import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/wallet_controller.dart';
import 'player_logic.dart';
@@ -11,11 +12,13 @@ class PlayerBinding {
PlayerLogic create({
required WatchHistoryRepository history,
required PlaybackRepository playback,
required PlaybackPositionCache positionCache,
WalletController? wallet,
DownloadController? downloads,
}) => PlayerLogic(
history: history,
playback: playback,
positionCache: positionCache,
pictureInPicture: AndroidPictureInPicture(),
wallet: wallet,
downloads: downloads,
+146 -32
View File
@@ -17,6 +17,7 @@ import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
@@ -33,6 +34,12 @@ import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
import 'widgets/episode_picker_sheet.dart';
typedef _PlaybackSnapshot = ({
Duration position,
Duration duration,
bool completed,
});
class PlayerLogic extends ChangeNotifier {
PlayerLogic({
required this.history,
@@ -40,8 +47,10 @@ class PlayerLogic extends ChangeNotifier {
required this.pictureInPicture,
this.wallet,
this.downloads,
PlaybackPositionCache? positionCache,
Future<void> Function()? waitForFrame,
}) : _waitForFrame =
}) : positionCache = positionCache ?? PlaybackPositionCache(),
_waitForFrame =
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame);
final WatchHistoryRepository history;
@@ -49,6 +58,7 @@ class PlayerLogic extends ChangeNotifier {
final AndroidPictureInPicture pictureInPicture;
final WalletController? wallet;
final DownloadController? downloads;
final PlaybackPositionCache positionCache;
final Future<void> Function() _waitForFrame;
late PageController pages;
@@ -115,6 +125,7 @@ class PlayerLogic extends ChangeNotifier {
bool _deferredFeedNotifyScheduled = false;
int _feedJumpSerial = 0;
Future<void> _feedUpdateFuture = Future<void>.value();
Future<void>? _pendingSeek;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
@@ -138,6 +149,7 @@ class PlayerLogic extends ChangeNotifier {
Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id];
if (number == null) return fallback;
if (fallback.number == number) return fallback;
for (final episode in drama.episodes) {
if (episode.number == number) return episode;
}
@@ -351,9 +363,22 @@ class PlayerLogic extends ChangeNotifier {
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
Future<void> loadResumePoint() async {
final dramaId = queue.entryAt(initialIndex).drama.id;
final entry = queue.entryAt(initialIndex);
final cached = positionCache.read(
entry.episode.id,
dramaId: entry.drama.id,
);
if (cached != null) {
resumePoint = ResumePoint(
episodeId: cached.episodeId,
position: cached.position,
);
resumeLoaded = true;
if (isMounted()) notify();
return;
}
try {
resumePoint = await history.resumePointFor(dramaId);
resumePoint = await history.resumePointFor(entry.drama.id);
} catch (_) {
// 拿不到续看位置不影响播放:从当前集开头看
resumePoint = null;
@@ -414,6 +439,7 @@ class PlayerLogic extends ChangeNotifier {
ticket.playUrl,
likeCount: ticket.likeCount,
commentCount: ticket.commentCount,
thumbnailsUrl: ticket.thumbnailsUrl,
);
return true;
}
@@ -639,9 +665,21 @@ class PlayerLogic extends ChangeNotifier {
await pendingWrite;
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint();
if (resumePositionFor(episode.id) == null) {
final dramaId = _dramaIdForEpisode(episode.id);
final cached = dramaId == null
? null
: positionCache.read(episode.id, dramaId: dramaId);
if (cached != null) {
resumePoint = ResumePoint(
episodeId: cached.episodeId,
position: cached.position,
);
}
}
final saved = resumePositionFor(episode.id);
if (saved != null) {
await player.controller.seekTo(
await player.seekTo(
clampResumePosition(saved, player.controller.value.duration),
);
}
@@ -664,23 +702,24 @@ class PlayerLogic extends ChangeNotifier {
var completionHandled = false;
var completionProgressSaved = false;
player.controller.addListener(() {
if (session.active != player || !player.controller.value.isInitialized) {
return;
}
if (player.controller.value.isCompleted && !completionProgressSaved) {
if (session.active != player) return;
final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return;
_cachePlaybackPosition(drama, episode, snapshot);
if (snapshot.completed && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
queueProgress(
drama,
episode,
player.controller.value.duration,
duration: player.controller.value.duration,
snapshot.duration,
duration: snapshot.duration,
finished: true,
),
);
}
if (!completionHandled &&
player.controller.value.isCompleted &&
snapshot.completed &&
session.canAutoAdvance &&
session.currentIndex == index) {
completionHandled = true;
@@ -688,7 +727,12 @@ class PlayerLogic extends ChangeNotifier {
}
if (!shouldReportProgress(DateTime.now())) return;
unawaited(
queueProgress(drama, episode, player.controller.value.position),
queueProgress(
drama,
episode,
snapshot.position,
duration: snapshot.duration,
),
);
});
}
@@ -740,21 +784,63 @@ class PlayerLogic extends ChangeNotifier {
return pendingWrite;
}
Future<void> saveCurrentProgress() {
Future<void> saveCurrentProgress() async {
final pendingSeek = _pendingSeek;
if (pendingSeek != null) await pendingSeek;
await _saveCurrentProgressNow();
}
Future<void> _saveCurrentProgressNow() {
final player = trackedPlayer;
final episode = trackedEpisode;
final drama = trackedDrama;
if (player == null ||
episode == null ||
drama == null ||
!player.controller.value.isInitialized) {
return pendingWrite;
if (player == null || episode == null || drama == null) return pendingWrite;
final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return pendingWrite;
return _persistPlaybackSnapshot(drama, episode, snapshot);
}
_PlaybackSnapshot? _readPlaybackSnapshot(VideoPlayerPort player) {
final value = player.controller.value;
if (!value.isInitialized || value.duration <= Duration.zero) return null;
return (
position: clampPlaybackPosition(value.position, value.duration),
duration: value.duration,
completed: value.isCompleted,
);
}
void _cachePlaybackPosition(
Drama drama,
Episode episode,
_PlaybackSnapshot snapshot,
) {
if (session.isLocked ||
ticketError != null ||
blockedEpisodeId == episode.id) {
return;
}
positionCache.save(
episodeId: episode.id,
dramaId: drama.id,
position: snapshot.position,
duration: snapshot.duration,
);
}
Future<void> _persistPlaybackSnapshot(
Drama drama,
Episode episode,
_PlaybackSnapshot snapshot, {
bool finished = false,
}) {
_cachePlaybackPosition(drama, episode, snapshot);
return queueProgress(
drama,
episode,
player.controller.value.position,
duration: player.controller.value.duration,
snapshot.position,
duration: snapshot.duration,
finished: finished,
);
}
@@ -815,23 +901,44 @@ class PlayerLogic extends ChangeNotifier {
BuildContext context,
VideoPlayerPort player,
Duration position,
) {
final operation = _performSeek(context, player, position);
_pendingSeek = operation;
return operation.whenComplete(() {
if (identical(_pendingSeek, operation)) _pendingSeek = null;
});
}
Future<void> _performSeek(
BuildContext context,
VideoPlayerPort player,
Duration position,
) async {
if (scrubbingPlayer != player || session.active != player) return;
try {
await session.playbackSettled;
if (scrubbingPlayer != player || session.active != player) return;
await player.controller.seekTo(position);
if (session.active == player) {
// 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。
resumeAfterSeek();
await saveCurrentProgress();
}
final duration = player.controller.value.duration;
final target = clampPlaybackPosition(position, duration);
if (target <= Duration.zero) return;
await player.seekTo(target);
if (session.active != player) return;
final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return;
final entry = queue.entryAt(session.currentIndex);
final save = _persistPlaybackSnapshot(
entry.drama,
entry.episode,
snapshot,
);
// 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。
resumeAfterSeek();
await save;
} catch (_) {
if (context.mounted && session.active == player) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
}
} finally {
if (scrubbingPlayer == player) cancelScrub();
@@ -851,6 +958,7 @@ class PlayerLogic extends ChangeNotifier {
Future<void> openDetail(BuildContext context) async {
session.setOverlayOpen(true);
await saveCurrentProgress();
try {
if (!context.mounted) return;
await Navigator.of(context).push(
@@ -865,6 +973,13 @@ class PlayerLogic extends ChangeNotifier {
await saveCurrentProgress();
}
String? _dramaIdForEpisode(String episodeId) {
for (final entry in queue.entries) {
if (entry.episode.id == episodeId) return entry.drama.id;
}
return null;
}
Future<void> openSeriesAt(
BuildContext context,
Drama drama,
@@ -964,9 +1079,8 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final action = await Navigator.of(
context,
).push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
final action = await Navigator.of(context)
.push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
if (!context.mounted) return;
if (action == MyPageAction.discover) {
await showDiscover(context);
+81 -13
View File
@@ -6,12 +6,16 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/comment_repository.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/sprite_thumbnail_axis.dart';
import '../../core/data/services/playback_thumbnail_service.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/error_text.dart';
@@ -102,6 +106,8 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends State<PlayerPage>
with WidgetsBindingObserver, RouteAware {
late final PlayerLogic logic;
late final PlaybackThumbnailService _thumbnailService;
late final SpriteThumbnailAxisService _spriteAxisService;
PageRoute<dynamic>? _subscribedRoute;
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
@@ -119,10 +125,15 @@ class _PlayerPageState extends State<PlayerPage>
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(),
wallet: widget.wallet,
downloads: widget.downloads,
);
logic.attach(widget, isMounted: () => mounted);
_thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource,
);
_spriteAxisService = SpriteThumbnailAxisService();
WidgetsBinding.instance.addObserver(this);
logic.start(
factory: context.read<PlayerFactory>(),
@@ -159,6 +170,57 @@ class _PlayerPageState extends State<PlayerPage>
String _feedSignature(List<Drama>? dramas) =>
dramas == null ? '' : dramas.map((drama) => drama.id).join('|');
PlaybackThumbnailSource? _resolveThumbnailSource(String episodeId) {
final localPath = widget.downloads?.localPathFor(episodeId);
Episode? episode;
for (final item in logic.sessionEpisodes) {
if (item.id == episodeId) {
episode = item;
break;
}
}
if (localPath != null && localPath.isNotEmpty) {
return PlaybackThumbnailSource(localPath: localPath);
}
final rawUrl = episode?.videoUrl ?? '';
final active = logic.session.active;
final activeSource = active is VideoPlayerPort
? active.controller.dataSource
: '';
final currentEpisodeId = logic.sessionEpisodes.isEmpty
? null
: logic.session.currentEpisode.id;
final activeMatches =
currentEpisodeId == episodeId && activeSource.isNotEmpty;
if (activeMatches && activeSource.startsWith('file:')) {
try {
return PlaybackThumbnailSource(
localPath: Uri.parse(activeSource).toFilePath(),
);
} catch (_) {
return null;
}
}
if (rawUrl.startsWith('file:')) {
try {
return PlaybackThumbnailSource(
localPath: Uri.parse(rawUrl).toFilePath(),
);
} catch (_) {
return null;
}
}
final remoteUrl = AppConfig.mediaUrl(
rawUrl.isNotEmpty
? rawUrl
: activeMatches
? activeSource
: '',
);
if (remoteUrl.isEmpty) return null;
return PlaybackThumbnailSource(remoteUrl: remoteUrl);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -188,6 +250,8 @@ class _PlayerPageState extends State<PlayerPage>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
appRouteObserver.unsubscribe(this);
_spriteAxisService.dispose();
_thumbnailService.dispose();
logic.dispose();
super.dispose();
}
@@ -362,9 +426,12 @@ class _PlayerPageState extends State<PlayerPage>
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = widget.homeMode
? logic.displayEpisode(drama, entry.episode)
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
? logic.sessionEpisodes[index]
: entry.episode;
final episode = widget.homeMode
? logic.displayEpisode(drama, sessionEpisode)
: sessionEpisode;
final selected = index == logic.session.currentIndex;
// Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder.
@@ -375,6 +442,14 @@ class _PlayerPageState extends State<PlayerPage>
final ready = videoPort?.controller.value.isInitialized ?? false;
final theater = logic.pip || logic.pipPreparing;
final locked = logic.session.isLocked || episode.locked;
if (selected && ready) {
unawaited(
_spriteAxisService.prepareAt(
episode.thumbnailsUrl,
videoPort!.controller.value.position,
),
);
}
return Stack(
fit: StackFit.expand,
@@ -440,17 +515,6 @@ class _PlayerPageState extends State<PlayerPage>
),
),
),
if (selected && logic.session.loading)
const Center(
child: SizedBox.square(
dimension: _playerLoadingSize,
child: CircularProgressIndicator(
key: Key('player-session-loading'),
color: AppColors.blue,
strokeWidth: _playerLoadingStrokeWidth,
),
),
),
if (selected && !ready && logic.session.error != null)
Center(
child: Padding(
@@ -556,6 +620,8 @@ class _PlayerPageState extends State<PlayerPage>
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
)
else
PlayerPlaybackOverlay(
@@ -578,6 +644,8 @@ class _PlayerPageState extends State<PlayerPage>
playbackSpeed: _playbackSpeed,
totalEpisodes: logic.visibleIndices.length,
boosting: _boosting,
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
),
actions: PlayerOverlayActions(
onBack: widget.embedded
@@ -7,12 +7,16 @@ import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/playback_thumbnail_service.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'interaction_rail.dart';
import 'loading_progress_track.dart';
import 'scrub_preview_card.dart';
import 'seek_progress_bar.dart';
part 'home_feed_overlay_parts.dart';
@@ -59,6 +63,8 @@ class HomeFeedOverlay extends StatefulWidget {
this.authorNote = '',
this.onScrubStart,
this.onSeek,
this.thumbnailService,
this.spriteAxisService,
});
final AppLocalizations l10n;
@@ -86,6 +92,8 @@ class HomeFeedOverlay extends StatefulWidget {
final VoidCallback onMore;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
@override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
@@ -287,7 +295,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
),
),
const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 20,
),
],
),
);
@@ -342,6 +354,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
child: SizedBox(
height: _fullBarHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Container(
@@ -394,6 +407,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
hitHeight: _progressHitHeight,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
),
),
],
@@ -17,6 +17,11 @@ class _FeedProgressBar extends StatefulWidget {
required this.hitHeight,
this.onScrubStart,
this.onSeek,
this.thumbnailService,
this.spriteAxisService,
this.thumbnailsUrl,
required this.episodeId,
required this.posterUrl,
});
final ValueListenable<VideoPlayerValue>? controller;
@@ -25,6 +30,11 @@ class _FeedProgressBar extends StatefulWidget {
final double hitHeight;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
final String? thumbnailsUrl;
final String episodeId;
final String posterUrl;
@override
State<_FeedProgressBar> createState() => _FeedProgressBarState();
@@ -51,6 +61,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
_bufferTimer?.cancel();
_bufferTimer = null;
_buffering = false;
widget.thumbnailService?.cancelPending();
_attachSource();
}
if (oldWidget.locked != widget.locked) {
@@ -58,6 +69,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
_bufferTimer = null;
_buffering = false;
if (widget.locked) {
widget.thumbnailService?.cancelPending();
_preview = null;
} else {
_syncBufferState(notify: false);
@@ -69,6 +81,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
void dispose() {
_detachSource(widget.controller);
_bufferTimer?.cancel();
if (_preview != null) widget.thumbnailService?.cancelPending();
super.dispose();
}
@@ -79,18 +92,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
final width = constraints.maxWidth;
final source = widget.controller;
final scrubbing = _preview != null;
final loading =
!widget.locked &&
(source == null ||
(!scrubbing && (!source.value.isInitialized || _buffering)));
final loading = !widget.locked && !scrubbing && _buffering;
final track = widget.locked
? _inactiveTrack()
: source == null
? _loadingTrack()
? _inactiveTrack()
: ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) {
if (!scrubbing && (!value.isInitialized || _buffering)) {
if (!value.isInitialized) return _inactiveTrack();
if (!scrubbing && _buffering) {
return _loadingTrack();
}
final total = value.duration.inMilliseconds;
@@ -102,7 +113,18 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
return _track(_preview ?? played, width);
},
);
final interactive = !widget.locked && !loading;
final interactive =
!widget.locked &&
!loading &&
(source?.value.isInitialized ?? false);
final video = source?.value;
final previewPosition = _preview == null || video == null
? null
: seekPositionForFraction(_preview!, video.duration);
final showPreview =
previewPosition != null &&
widget.thumbnailService != null &&
widget.episodeId.isNotEmpty;
return GestureDetector(
key: const Key('home-feed-progress'),
behavior: HitTestBehavior.opaque,
@@ -116,10 +138,30 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
? (details) => _update(details.localPosition.dx / width)
: null,
onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null,
onHorizontalDragCancel: interactive
? () => setState(() => _preview = null)
: null,
child: track,
onHorizontalDragCancel: interactive ? _cancelPreview : null,
child: Stack(
clipBehavior: Clip.none,
children: [
track,
if (showPreview)
Positioned(
left: scrubPreviewLeft(
trackWidth: width,
fraction: _preview!,
),
bottom: widget.hitHeight + 8,
child: ScrubPreviewCard(
service: widget.thumbnailService!,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.thumbnailsUrl,
episodeId: widget.episodeId,
posterUrl: widget.posterUrl,
position: previewPosition,
duration: video!.duration,
),
),
],
),
);
},
);
@@ -148,7 +190,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
if (!value.isInitialized) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(true, notify: notify);
_setBuffering(false, notify: notify);
return;
}
if (!value.isBuffering) {
@@ -229,6 +271,12 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
);
}
widget.thumbnailService?.cancelPending();
setState(() => _preview = null);
}
void _cancelPreview() {
widget.thumbnailService?.cancelPending();
setState(() => _preview = null);
}
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
@@ -264,6 +265,11 @@ class PlayerPlaybackOverlay extends StatelessWidget {
showTimes: true,
compact: true,
locked: data.locked,
thumbnailService: data.thumbnailService,
spriteAxisService: data.spriteAxisService,
thumbnailsUrl: data.episode.thumbnailsUrl,
episodeId: data.episode.id,
posterUrl: data.drama.posterUrl,
inactiveTrackColor: Colors.white24,
onScrubStart: actions.onScrubStart,
onSeek: actions.onSeek,
@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/playback_thumbnail_service.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../l10n/app_localizations.dart';
/// 播放浮层展示数据。
@@ -24,6 +26,8 @@ class PlayerOverlayData {
required this.totalEpisodes,
required this.boosting,
required this.locked,
this.thumbnailService,
this.spriteAxisService,
this.authorNote = '',
});
@@ -69,6 +73,10 @@ class PlayerOverlayData {
/// 当前剧集是否锁定。
final bool locked;
/// 拖动进度时按当前集和时间点取帧。
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
/// 作者声明;没有后端字段时为空。
final String authorNote;
}
@@ -0,0 +1,291 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../core/data/services/playback_thumbnail_service.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
const double scrubPreviewWidth = 120;
const double scrubPreviewSafeMargin = 12;
/// 把预览卡片限制在进度条可用宽度内。
double scrubPreviewLeft({
required double trackWidth,
required double fraction,
double cardWidth = scrubPreviewWidth,
double safeMargin = scrubPreviewSafeMargin,
}) {
final maxLeft = trackWidth - cardWidth - safeMargin;
if (maxLeft < safeMargin) {
return ((trackWidth - cardWidth) / 2).clamp(0.0, trackWidth).toDouble();
}
final requested = trackWidth * fraction.clamp(0.0, 1.0) - cardWidth / 2;
return requested.clamp(safeMargin, maxLeft).toDouble();
}
/// 拖动进度时覆盖在轨道上方的单帧预览。
class ScrubPreviewCard extends StatefulWidget {
const ScrubPreviewCard({
super.key,
required this.service,
required this.episodeId,
required this.posterUrl,
required this.position,
required this.duration,
this.spriteAxisService,
this.thumbnailsUrl,
});
final PlaybackThumbnailService service;
final SpriteThumbnailAxisSource? spriteAxisService;
final String? thumbnailsUrl;
final String episodeId;
final String posterUrl;
final Duration position;
final Duration duration;
@override
State<ScrubPreviewCard> createState() => _ScrubPreviewCardState();
}
class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
ByteData? _fallbackFrame;
int _requestSerial = 0;
int? _requestedBucket;
String? _requestedEpisodeId;
@override
void initState() {
super.initState();
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
_syncFallbackSource();
}
@override
void didUpdateWidget(covariant ScrubPreviewCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.spriteAxisService != widget.spriteAxisService) {
oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
}
if (oldWidget.service != widget.service ||
oldWidget.episodeId != widget.episodeId ||
oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
_resetFallback();
_syncFallbackSource();
return;
}
if (_requestedBucket != playbackThumbnailBucket(widget.position)) {
_fallbackFrame = null;
_syncFallbackSource();
} else if (_fallbackFrame != null || _shouldUseLocalFallback()) {
unawaited(widget.service.thumbnail(widget.episodeId, widget.position));
}
}
@override
void dispose() {
widget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
_requestSerial++;
widget.service.cancelPending();
super.dispose();
}
void _handleSpriteAxisChanged() {
if (!mounted) return;
if (_spriteFrame != null) {
widget.service.cancelPending();
}
setState(() {});
_syncFallbackSource();
}
SpriteThumbnailFrame? get _spriteFrame => widget.spriteAxisService
?.cachedFrameAt(widget.thumbnailsUrl, widget.position);
bool _shouldUseLocalFallback() {
if (_spriteFrame != null) return false;
final url = widget.thumbnailsUrl?.trim() ?? '';
if (url.isEmpty || widget.spriteAxisService == null) return true;
return widget.spriteAxisService!.hasAxis(widget.thumbnailsUrl);
}
void _syncFallbackSource() {
if (_spriteFrame != null) {
widget.service.cancelPending();
return;
}
if (!_shouldUseLocalFallback()) return;
_requestFallbackFrame();
}
void _requestFallbackFrame() {
final bucket = playbackThumbnailBucket(widget.position);
if (_requestedBucket == bucket &&
_requestedEpisodeId == widget.episodeId &&
(_fallbackFrame != null || _requestSerial > 0)) {
return;
}
_requestedBucket = bucket;
_requestedEpisodeId = widget.episodeId;
final serial = ++_requestSerial;
widget.service.thumbnail(widget.episodeId, widget.position).then((frame) {
if (!mounted || serial != _requestSerial || _spriteFrame != null) return;
setState(() => _fallbackFrame = frame);
});
}
void _resetFallback() {
widget.service.cancelPending();
_fallbackFrame = null;
_requestedBucket = null;
_requestedEpisodeId = null;
_requestSerial++;
}
@override
Widget build(BuildContext context) {
final spriteFrame = _spriteFrame;
final stage = decideScrubPreviewStage(
hasThumbnailsUrl: (widget.thumbnailsUrl?.trim().isNotEmpty ?? false),
hasSpriteFrame: spriteFrame != null,
hasAxis: widget.spriteAxisService?.hasAxis(widget.thumbnailsUrl) ?? false,
hasLocalFrame: _fallbackFrame != null,
);
return Container(
key: const Key('scrub-preview-card'),
width: scrubPreviewWidth,
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white, width: 1),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
color: Colors.black87,
child: Text(
'${_format(widget.position)} / ${_format(widget.duration)}',
key: const Key('scrub-preview-time'),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
widget.posterUrl,
key: const Key('scrub-preview-poster'),
fit: BoxFit.cover,
fallbackColor: AppColors.bar,
),
if (stage == ScrubPreviewStage.sprite && spriteFrame != null)
SpriteCropPreview(
key: const Key('scrub-preview-sprite'),
image: spriteFrame.image,
sourceRect: spriteFrame.sourceRect,
),
if (stage == ScrubPreviewStage.localFrame &&
_fallbackFrame != null)
Image.memory(
_fallbackFrame!.buffer.asUint8List(
_fallbackFrame!.offsetInBytes,
_fallbackFrame!.lengthInBytes,
),
key: const Key('scrub-preview-frame'),
fit: BoxFit.cover,
cacheWidth: 240,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
],
),
),
],
),
),
);
}
}
/// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。
class SpriteCropPreview extends StatelessWidget {
const SpriteCropPreview({
super.key,
required this.image,
required this.sourceRect,
});
final ui.Image image;
final ui.Rect sourceRect;
@override
Widget build(BuildContext context) => CustomPaint(
painter: _SpriteCropPainter(image: image, sourceRect: sourceRect),
);
}
class _SpriteCropPainter extends CustomPainter {
const _SpriteCropPainter({required this.image, required this.sourceRect});
final ui.Image image;
final ui.Rect sourceRect;
@override
void paint(Canvas canvas, Size size) {
final fitted = applyBoxFit(BoxFit.cover, sourceRect.size, size);
final sourceOffset = Alignment.center.inscribe(
fitted.source,
Offset.zero & sourceRect.size,
);
final source = Rect.fromLTWH(
sourceRect.left + sourceOffset.left,
sourceRect.top + sourceOffset.top,
fitted.source.width,
fitted.source.height,
);
final destination = Alignment.center.inscribe(
fitted.destination,
Offset.zero & size,
);
canvas.drawImageRect(
image,
source,
destination,
Paint()
..filterQuality = FilterQuality.low
..isAntiAlias = false,
);
}
@override
bool shouldRepaint(covariant _SpriteCropPainter oldDelegate) =>
oldDelegate.image != image || oldDelegate.sourceRect != sourceRect;
}
String _format(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
+120 -75
View File
@@ -6,8 +6,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/services/playback_thumbnail_service.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/theme/app_colors.dart';
import 'loading_progress_track.dart';
import 'scrub_preview_card.dart';
const Key _inactiveTrackKey = Key('progress-inactive');
@@ -32,6 +35,11 @@ class SeekProgressBar extends StatefulWidget {
this.compact = false,
this.inactiveTrackColor = AppColors.border,
this.locked = false,
this.thumbnailService,
this.spriteAxisService,
this.episodeId,
this.posterUrl = '',
this.thumbnailsUrl,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
@@ -42,6 +50,11 @@ class SeekProgressBar extends StatefulWidget {
final bool compact;
final Color inactiveTrackColor;
final bool locked;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
final String? episodeId;
final String posterUrl;
final String? thumbnailsUrl;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
@@ -69,6 +82,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
_bufferTimer?.cancel();
_bufferTimer = null;
_buffering = false;
widget.thumbnailService?.cancelPending();
_attachSource();
}
if (oldWidget.locked != widget.locked) {
@@ -76,6 +90,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
_bufferTimer = null;
_buffering = false;
if (widget.locked) {
widget.thumbnailService?.cancelPending();
_previewFraction.value = null;
} else {
_syncBufferState(notify: false);
@@ -87,6 +102,9 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
void dispose() {
_detachSource(widget.valueListenable);
_bufferTimer?.cancel();
if (_previewFraction.value != null) {
widget.thumbnailService?.cancelPending();
}
_previewFraction.dispose();
super.dispose();
}
@@ -114,7 +132,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
if (!value.isInitialized) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(true, notify: notify);
_setBuffering(false, notify: notify);
return;
}
if (!value.isBuffering) {
@@ -148,15 +166,10 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
Future<void> _finish(double fraction, Duration duration) async {
if (widget.locked) return;
final source = widget.valueListenable;
_previewFraction.value = fraction;
try {
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
} finally {
if (mounted && widget.valueListenable == source) {
_previewFraction.value = null;
}
}
widget.thumbnailService?.cancelPending();
_previewFraction.value = null;
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
}
@override
@@ -181,8 +194,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
Widget _buildTrack(BuildContext context, VideoPlayerValue video) {
final total = video.duration.inMilliseconds;
final scrubbing = _previewFraction.value != null;
final loading =
!widget.locked && !scrubbing && (!video.isInitialized || _buffering);
final loading = !widget.locked && !scrubbing && _buffering;
final enabled =
!widget.locked &&
video.isInitialized &&
@@ -195,6 +207,11 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
final previewPosition = _previewFraction.value == null
? video.position
: seekPositionForFraction(fraction, video.duration);
final showPreview =
scrubbing &&
widget.showTimes &&
widget.thumbnailService != null &&
(widget.episodeId?.isNotEmpty ?? false);
final trackHeight = widget.compact
? (scrubbing ? 3.0 : 1.5)
: (scrubbing ? 4.0 : 3.0);
@@ -204,72 +221,100 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
? (scrubbing ? 32.0 : 20.0)
: (scrubbing ? 48.0 : 44.0);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.showTimes && scrubbing)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_format(previewPosition)} / ${_format(video.duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
SizedBox(
height: trackAreaHeight,
child: widget.locked
? _inactiveTrack(trackHeight)
: loading
? Center(
child: LoadingProgressTrack(
height: trackHeight,
trackColor: widget.inactiveTrackColor,
),
)
: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: trackHeight,
activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.inactiveTrackColor,
thumbColor: AppColors.onPrimary,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compact
? (scrubbing ? 6 : 4)
: (scrubbing ? 8 : 6),
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compact ? 12 : 18,
),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
padding: EdgeInsets.zero,
onChangeStart: enabled
? (value) {
_previewFraction.value = value;
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => _previewFraction.value = value
: null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))
: null,
),
return LayoutBuilder(
builder: (context, constraints) {
final track = Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.showTimes && scrubbing && !showPreview)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_format(previewPosition)} / ${_format(video.duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
],
),
SizedBox(
height: trackAreaHeight,
child: widget.locked || !video.isInitialized
? _inactiveTrack(trackHeight)
: loading
? Center(
child: LoadingProgressTrack(
height: trackHeight,
trackColor: widget.inactiveTrackColor,
),
)
: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: trackHeight,
activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.inactiveTrackColor,
thumbColor: AppColors.onPrimary,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compact
? (scrubbing ? 6 : 4)
: (scrubbing ? 8 : 6),
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compact ? 12 : 18,
),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
padding: EdgeInsets.zero,
onChangeStart: enabled
? (value) {
_previewFraction.value = value;
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => _previewFraction.value = value
: null,
onChangeEnd: enabled
? (value) =>
unawaited(_finish(value, video.duration))
: null,
),
),
),
),
],
);
return Stack(
clipBehavior: Clip.none,
children: [
track,
if (showPreview)
Positioned(
left: scrubPreviewLeft(
trackWidth: constraints.maxWidth,
fraction: fraction,
),
bottom: trackAreaHeight + 8,
child: ScrubPreviewCard(
service: widget.thumbnailService!,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.thumbnailsUrl,
episodeId: widget.episodeId!,
posterUrl: widget.posterUrl,
position: previewPosition,
duration: video.duration,
),
),
],
);
},
);
}
+50 -13
View File
@@ -17,6 +17,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
@@ -26,6 +27,7 @@ import 'widgets/watch_record_card.dart';
const double _pageHorizontal = 14;
const double _statsFontSize = 12;
const double _emptyListHeight = 360;
const double _loadMoreThreshold = 240;
/// 独立观看记录页。
class WatchRecordsPage extends StatelessWidget {
@@ -138,34 +140,59 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
record: record,
onRetry: () => controller.retryDetail(record.dramaId),
onRemove: () => _remove(record.dramaId),
onOpen: record.drama == null ? null : () => _open(record),
onOpen: () => _open(record),
),
],
PagedListFooter(
loading: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.records.isNotEmpty,
errorMessage: controller.loadMoreError == null
? null
: errorText(l10n, controller.loadMoreError, l10n.progressError),
onRetry: controller.loadMore,
),
],
);
}
Widget _refreshable({required List<Widget> children}) {
return AppPullToRefresh(
onRefresh: controller.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: controller.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
),
children: children,
),
children: children,
),
);
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(controller.loadMore());
}
return false;
}
Widget _summary(AppLocalizations l10n) {
return Row(
children: [
Text(
l10n.watchRecordsCount(controller.records.length),
l10n.watchRecordsCount(controller.total),
key: const Key('watch-records-count'),
style: const TextStyle(
color: AppColors.muted,
@@ -192,10 +219,20 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
}
Future<void> _open(WatchRecord record) async {
final drama =
record.drama ?? await controller.detailForOpen(record.dramaId);
if (!mounted) return;
if (drama == null) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed)));
return;
}
final episodeId = record.progress.episodeId;
await playDramaFromCatalog(
context,
record.drama!,
drama,
episodeId: episodeId.isEmpty ? null : episodeId,
);
}
@@ -39,17 +39,22 @@ class WatchRecordCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (record.detailFailed) {
final hasMetadata = record.hasDisplayMetadata;
if (record.detailFailed && !hasMetadata) {
return _FailedRecordTile(record: record, onRetry: onRetry);
}
final drama = record.drama;
if (drama == null || record.detailLoading) {
if (!hasMetadata && (drama == null || record.detailLoading)) {
return _loadingCard();
}
final l10n = AppLocalizations.of(context)!;
final episodeLabel = record.episodeNumber > 0
final episodeNumber = record.episodeNumber;
final episodeLabel = episodeNumber > 0
? l10n.episodeLabel(record.episodeNumber)
: l10n.episodeUnavailable;
final title = record.displayTitle.isEmpty
? episodeLabel
: record.displayTitle;
return _card(
onTap: onOpen,
child: Stack(
@@ -60,10 +65,14 @@ class WatchRecordCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(_coverRadius),
child: RemoteImage(
drama.posterUrl,
record.displayCover,
key: ValueKey('watch-record-cover-${record.dramaId}'),
width: _coverWidth,
height: _coverHeight,
showFallbackIcon: true,
fallbackIconKey: ValueKey(
'watch-record-cover-placeholder-${record.dramaId}',
),
),
),
const SizedBox(width: 12),
@@ -74,7 +83,7 @@ class WatchRecordCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
title,
key: ValueKey('watch-record-title-${record.dramaId}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -85,14 +94,18 @@ class WatchRecordCard extends StatelessWidget {
),
),
const SizedBox(height: 4),
Text(
episodeLabel,
key: ValueKey('watch-record-episode-${record.dramaId}'),
style: const TextStyle(
color: AppColors.muted,
fontSize: _statsFontSize,
if (title != episodeLabel) ...[
Text(
episodeLabel,
key: ValueKey(
'watch-record-episode-${record.dramaId}',
),
style: const TextStyle(
color: AppColors.muted,
fontSize: _statsFontSize,
),
),
),
],
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(_progressRadius),
@@ -0,0 +1,203 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_interceptors.dart';
import 'package:fruitshorts/core/data/remote/network_log_interceptor.dart';
void main() {
const baseUrl = 'http://logging.test';
const token = 'eyJhbGciOiJIUzM4NCJ9.secret';
Dio buildDio(
List<String> logs,
HttpClientAdapter adapter, {
bool useEnvelope = true,
}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
responseType: ResponseType.bytes,
validateStatus: (_) => true,
),
);
dio.httpClientAdapter = adapter;
dio.interceptors.addAll([
AuthHeaderInterceptor(
accessTokenReader: () => token,
deviceIdReader: () async => 'device-raw-001',
),
if (useEnvelope) const EnvelopeInterceptor(),
NetworkLogInterceptor(logger: logs.add),
]);
return dio;
}
test(
'should_log_request_and_response_lines_with_elapsed_when_requested',
() async {
final logs = <String>[];
final dio = buildDio(
logs,
_RawHttpAdapter.fromText(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {'title': '中文标题'},
}),
),
);
await dio.post<void>(
'/app/logging',
queryParameters: {'keyword': '中文'},
data: {'filter': '中文'},
);
expect(
logs.first,
contains('[HTTP] → POST $baseUrl/app/logging?keyword='),
);
expect(logs.join('\n'), contains('[HTTP] → 耗时起点:'));
expect(logs.join('\n'), matches(RegExp(r'\[HTTP\] ← 200 \d+ms POST ')));
expect(logs.join('\n'), contains('"filter": "中文"'));
expect(logs.join('\n'), contains('"中文标题"'));
expect(logs.join('\n'), contains('"Authorization":"Bearer e..."'));
expect(logs.join('\n'), contains('"X-Device-Id":"device-raw-001"'));
expect(logs.join('\n'), isNot(contains(token)));
},
);
test('should_truncate_long_body_when_log_exceeds_limit', () async {
final logs = <String>[];
final dio = buildDio(
logs,
_RawHttpAdapter.fromText(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {'text': List.filled(3500, '长').join()},
}),
),
);
await dio.get<void>('/app/long');
final responseLog = logs.singleWhere(
(line) => line.startsWith('[HTTP] ← 200'),
);
const prefix = '[HTTP] ← body:';
final body = responseLog.substring(
responseLog.indexOf(prefix) + prefix.length,
);
expect(body.length, NetworkLogInterceptor.maxBodyLength);
expect(body, endsWith('…(truncated)'));
});
test(
'should_log_plain_text_without_throwing_when_response_is_not_json',
() async {
final logs = <String>[];
final dio = buildDio(
logs,
_RawHttpAdapter.fromText('plain text response'),
);
await expectLater(
dio.get<void>('/app/plain'),
throwsA(isA<DioException>()),
);
expect(logs.join('\n'), contains('[HTTP] ✗'));
expect(logs.join('\n'), contains('plain text response'));
},
);
test('should_log_byte_count_only_when_response_is_image', () async {
final logs = <String>[];
final dio = buildDio(
logs,
_RawHttpAdapter(
Uint8List.fromList([0xff, 0xd8, 0xff, 0x00]),
contentType: 'image/jpeg',
),
useEnvelope: false,
);
await dio.get<void>('/files/image.jpg');
expect(logs.join('\n'), contains('<binary image/jpeg, 4 bytes>'));
expect(logs.join('\n'), isNot(contains('�')));
});
test(
'should_log_dio_exception_type_and_message_when_request_fails',
() async {
final logs = <String>[];
final dio = buildDio(logs, _ThrowingHttpAdapter());
await expectLater(
dio.get<void>('/app/offline'),
throwsA(isA<DioException>()),
);
expect(
logs.join('\n'),
matches(RegExp(r'\[HTTP\] ✗ connectionError \d+ms .* offline')),
);
},
);
}
class _RawHttpAdapter implements HttpClientAdapter {
_RawHttpAdapter(this.bytes, {required this.contentType});
factory _RawHttpAdapter.fromText(String body) {
return _RawHttpAdapter(
Uint8List.fromList(utf8.encode(body)),
contentType: 'application/json; charset=utf-8',
);
}
final Uint8List bytes;
final String contentType;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
return ResponseBody.fromBytes(
bytes,
200,
headers: {
Headers.contentTypeHeader: [contentType],
},
);
}
@override
void close({bool force = false}) {}
}
class _ThrowingHttpAdapter implements HttpClientAdapter {
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) {
throw DioException(
requestOptions: options,
type: DioExceptionType.connectionError,
message: 'offline',
);
}
@override
void close({bool force = false}) {}
}
+59
View File
@@ -0,0 +1,59 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/page_result.dart';
void main() {
group('PageResult.parse', () {
test('should_parse_page_map_when_total_and_list_present', () {
final page = PageResult.parse({
'total': '3',
'list': [
{'id': '1', 'title': 'A'},
{'id': '2', 'title': 'B'},
],
}, _item);
expect(page.total, 3);
expect(page.list.map((e) => e['id']), ['1', '2']);
});
test('should_parse_legacy_list_when_data_is_array', () {
final page = PageResult.parse([
{'id': '9', 'title': '收藏剧'},
{'id': '8', 'title': '另一部'},
], _item);
expect(page.total, 2);
expect(page.list, hasLength(2));
expect(page.list.first['title'], '收藏剧');
});
test('should_return_empty_page_when_data_is_null_or_empty_list', () {
expect(PageResult.parse(null, _item).total, 0);
expect(PageResult.parse(null, _item).list, isEmpty);
expect(PageResult.parse(const <Object>[], _item).total, 0);
expect(PageResult.parse(const <Object>[], _item).list, isEmpty);
});
test('should_return_empty_page_when_payload_is_invalid', () {
final page = PageResult.parse('broken', _item);
expect(page.total, 0);
expect(page.list, isEmpty);
});
test('should_skip_non_map_entries_in_legacy_list', () {
final page = PageResult.parse([
{'id': '1'},
'noise',
42,
{'id': '2'},
], _item);
expect(page.total, 2);
expect(page.list.map((e) => e['id']), ['1', '2']);
});
});
}
Map<String, dynamic> _item(Map<String, dynamic> json) => json;
@@ -14,6 +14,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
@@ -102,6 +103,7 @@ Future<void> _pumpDetail(
value: InMemoryWatchHistoryRepository(),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlaybackPositionCache>(create: (_) => PlaybackPositionCache()),
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
ChangeNotifierProvider(
create: (_) => CatalogController(catalog)..load(),
@@ -1,9 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
import '../support/fake_watch_history.dart';
@@ -13,12 +15,14 @@ DramaDetailLogic _logic(
Drama drama,
FakeWatchHistory history, {
FavoriteController? favorites,
PlaybackPositionCache? positionCache,
}) =>
DramaDetailLogic(
drama: drama,
history: history,
playback: FakePlaybackRepository(),
favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()),
positionCache: positionCache ?? PlaybackPositionCache(),
);
Drama _card(String id) => Drama(
@@ -103,6 +107,31 @@ void main() {
logic.dispose();
});
test('should_prefer_recent_local_position_when_loading_continue', () async {
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'signal-3',
dramaId: 'signal',
position: const Duration(minutes: 1, seconds: 20),
duration: const Duration(minutes: 3),
);
final history = FakeWatchHistory()
..records['signal'] = const WatchProgress(
dramaId: 'signal',
episodeId: 'signal-1',
position: Duration(seconds: 5),
duration: Duration(minutes: 3),
);
final logic = _logic(_detail('signal'), history, positionCache: cache);
await logic.load(() async => _detail('signal'));
expect(logic.continueIndex, 2);
expect(logic.progress?.position, const Duration(seconds: 80));
expect(history.reads, isEmpty);
logic.dispose();
});
test('should_adjust_favorite_count_only_after_successful_changes', () async {
final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]);
final favorites = FavoriteController(repository);
@@ -27,6 +27,7 @@ import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
@@ -259,6 +260,25 @@ void main() {
expect(player.initialIndex, 79);
});
testWidgets('should_open_recent_cached_episode_when_detail_cta_is_tapped', (
tester,
) async {
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'detail-k38-3',
dramaId: 'detail-k38',
position: const Duration(minutes: 1, seconds: 20),
duration: const Duration(minutes: 3),
);
await _pumpDetail(tester, positionCache: cache);
await tester.tap(find.byKey(const Key('detail-cta')));
await tester.pumpAndSettle();
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
expect(player.initialIndex, 2);
});
testWidgets('should_fit_compact_viewport_and_keep_cta_visible', (
tester,
) async {
@@ -283,6 +303,7 @@ Future<void> _pumpDetail(
WidgetTester tester, {
Drama? drama,
Size size = const Size(390, 844),
PlaybackPositionCache? positionCache,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@@ -303,6 +324,9 @@ Future<void> _pumpDetail(
value: InMemoryWatchHistoryRepository(),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlaybackPositionCache>.value(
value: positionCache ?? PlaybackPositionCache(),
),
Provider<PlayerFactory>.value(value: createWidgetPlayer),
Provider(create: (_) => PlaybackCoordinator()),
Provider<InteractionRepository>.value(value: interactions),
+160 -1
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -13,6 +14,7 @@ import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
@@ -180,7 +182,11 @@ void main() {
.colorBlendMode,
BlendMode.srcIn,
);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(
find.byKey(const Key('home-feed-progress-inactive')),
findsOneWidget,
);
expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing);
});
@@ -258,6 +264,65 @@ void main() {
expect(seeks, 0);
});
testWidgets('should_show_static_track_when_feed_video_is_uninitialized', (
tester,
) async {
final controller = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue.uninitialized(),
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Builder(
builder: (context) => Scaffold(
body: HomeFeedOverlay(
l10n: AppLocalizations.of(context)!,
drama: const Drama(
id: 'loading-feed',
title: 'Loading Feed',
synopsis: 'synopsis',
posterUrl: '',
),
episode: const Episode(
id: 'loading-feed-1',
number: 1,
title: 'Loading',
isFree: true,
),
controller: controller,
locked: false,
liked: false,
saved: false,
visible: true,
boosting: false,
onTogglePlay: () {},
onBoostStart: () {},
onBoostEnd: () {},
onLike: () {},
onSave: () {},
onComments: () {},
onShare: () {},
onDetail: () {},
onOpenFullPlayer: () {},
onMore: () {},
onSeek: (_) {},
),
),
),
),
);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(
find.byKey(const Key('home-feed-progress-inactive')),
findsOneWidget,
);
});
testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async {
var opened = 0;
Duration? seeked;
@@ -546,6 +611,83 @@ void main() {
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
});
testWidgets('should_show_scrub_preview_card_from_home_progress', (
tester,
) async {
final controller = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue(
duration: Duration(seconds: 100),
position: Duration(seconds: 10),
isInitialized: true,
),
);
final service = _nullThumbnailService();
addTearDown(controller.dispose);
addTearDown(service.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Builder(
builder: (context) => Scaffold(
body: HomeFeedOverlay(
l10n: AppLocalizations.of(context)!,
drama: const Drama(
id: 'preview-drama',
title: 'Preview Drama',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 8,
),
episode: const Episode(
id: 'preview-episode',
number: 1,
title: 'Episode 1',
isFree: true,
),
controller: controller,
locked: false,
liked: false,
saved: false,
visible: true,
boosting: false,
thumbnailService: service,
onTogglePlay: () {},
onBoostStart: () {},
onBoostEnd: () {},
onLike: () {},
onSave: () {},
onComments: () {},
onShare: () {},
onDetail: () {},
onOpenFullPlayer: () {},
onMore: () {},
onScrubStart: () {},
onSeek: (_) {},
),
),
),
),
);
await tester.pumpAndSettle();
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
);
await gesture.moveBy(const Offset(60, 0));
await tester.pump();
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
expect(find.textContaining('/ 01:40'), findsOneWidget);
await gesture.up();
await tester.pump();
expect(find.byKey(const Key('scrub-preview-card')), findsNothing);
});
testWidgets('should_seek_from_series_bar_when_overlay_is_cleared', (
tester,
) async {
@@ -620,3 +762,20 @@ void main() {
expect(seeked!.inSeconds, greaterThan(0));
});
}
PlaybackThumbnailService _nullThumbnailService() {
return PlaybackThumbnailService(
frameLoader: _NullThumbnailLoader(),
sourceResolver: (_) =>
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
prefetchDelay: const Duration(hours: 1),
);
}
class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader {
@override
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async => null;
@override
void dispose() {}
}
+20 -5
View File
@@ -14,7 +14,8 @@ import 'package:fruitshorts/core/data/remote/watch_api.dart';
/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。
void main() {
const baseUrl = 'http://127.0.0.1:8081';
final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com';
final email =
'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com';
const password = 'Passw0rd!23';
AuthSession? session;
@@ -57,6 +58,20 @@ void main() {
}
expect(session?.user.uid, isNotEmpty);
final recentProbe = await client.get(
'/app/watch/recent',
query: {'pageIndex': '1', 'pageSize': '1'},
);
final favoriteProbe = await client.get(
'/app/favorites',
query: {'pageIndex': '1', 'pageSize': '1'},
);
if (recentProbe is! Map<String, dynamic> ||
favoriteProbe is! Map<String, dynamic>) {
markTestSkipped('运行中的后端仍返回旧版 List,分页契约联调已跳过');
return;
}
// 1) 首页 → 取第一部剧
final feed = await catalog.homeFeed();
expect(feed.dramas, isNotEmpty);
@@ -98,17 +113,17 @@ void main() {
expect(resume?.position, position);
// 最近观看里能看到这部剧
final recent = await watch.recent(limit: 20);
expect(recent.map((item) => item.dramaId), contains(card.id));
final recent = await watch.recent(pageSize: 20);
expect(recent.list.map((item) => item.dramaId), contains(card.id));
// 5) 收藏 → 出现在收藏列表 → 取消收藏
final savedState = await library.toggleFavorite(card.id);
expect(savedState, isTrue);
final favorites = await library.favorites();
expect(favorites.map((drama) => drama.id), contains(card.id));
expect(favorites.list.map((drama) => drama.id), contains(card.id));
await library.removeFavorite(card.id);
final afterRemove = await library.favorites();
expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id)));
expect(afterRemove.list.map((drama) => drama.id), isNot(contains(card.id)));
});
}
+64 -1
View File
@@ -2,6 +2,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/page_result.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
@@ -49,7 +50,7 @@ void main() {
});
test('should_expose_error_and_keep_old_state_when_list_fails', () async {
final repository = InMemoryFavoriteRepository()..failList = true;
final repository = InMemoryFavoriteRepository()..failPage = true;
final controller = FavoriteController(repository);
await controller.load();
@@ -115,4 +116,66 @@ void main() {
expect(controller.savedIds, isEmpty);
expect(controller.cards, isEmpty);
});
test('should_append_unique_cards_and_stop_when_no_more_pages', () async {
final repository = _PagedFavoriteRepository({
1: PageResult(total: 3, list: [_card('a'), _card('b')]),
2: PageResult(total: 3, list: [_card('b'), _card('c')]),
});
final controller = FavoriteController(repository, pageSize: 2);
await controller.load();
await controller.loadMore();
await controller.loadMore();
expect(controller.cards.map((card) => card.id), ['a', 'b', 'c']);
expect(controller.total, 3);
expect(controller.hasMore, isFalse);
expect(repository.pageRequests, [1, 2]);
});
test('should_retry_next_page_when_load_more_fails', () async {
final repository = _PagedFavoriteRepository({
1: PageResult(total: 2, list: [_card('a')]),
2: PageResult(total: 2, list: [_card('b')]),
})..failPages.add(2);
final controller = FavoriteController(repository, pageSize: 1);
await controller.load();
await controller.loadMore();
expect(controller.cards.map((card) => card.id), ['a']);
expect(controller.loadMoreError, 'favoritesError');
expect(controller.hasMore, isTrue);
repository.failPages.clear();
await controller.loadMore();
expect(controller.cards.map((card) => card.id), ['a', 'b']);
expect(controller.loadMoreError, isNull);
expect(repository.pageRequests, [1, 2, 2]);
});
}
class _PagedFavoriteRepository implements FavoriteRepository {
_PagedFavoriteRepository(this.pages);
final Map<int, PageResult<Drama>> pages;
final List<int> pageRequests = [];
final Set<int> failPages = {};
@override
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
pageRequests.add(pageIndex);
if (failPages.contains(pageIndex)) {
throw StateError('page unavailable');
}
return pages[pageIndex] ?? const PageResult();
}
@override
Future<void> remove(String dramaId) async {}
@override
Future<bool> toggle(String dramaId) async => true;
}
+44 -4
View File
@@ -32,10 +32,10 @@ import '../support/fake_interaction_repositories.dart';
import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
Drama _card(String id) => Drama(
Drama _card(String id, {String synopsis = 'synopsis'}) => Drama(
id: id,
title: 'Drama $id',
synopsis: 'synopsis',
synopsis: synopsis,
posterUrl: '/files/$id.png',
totalEpisodes: 12,
);
@@ -73,6 +73,14 @@ void main() {
);
expect(title.style?.color, AppColors.onSurface);
expect(title.style?.fontSize, 15);
final synopsis = tester.widget<Text>(
find.byKey(const Key('favorite-synopsis-signal')),
);
expect(synopsis.data, 'synopsis');
expect(synopsis.maxLines, 2);
expect(synopsis.overflow, TextOverflow.ellipsis);
expect(synopsis.style?.color, AppColors.muted);
expect(synopsis.style?.fontSize, 12);
expect(
tester.getSize(find.byKey(const Key('favorite-cover-signal'))),
const Size(80, 106),
@@ -100,6 +108,38 @@ void main() {
expect(find.byKey(const Key('favorite-card-signal')), findsNothing);
});
testWidgets('should_hide_blank_synopsis_without_changing_card_height', (
tester,
) async {
final auth = await _auth(signedIn: true);
final longSynopsis = List.filled(12, '很长的剧情简介').join();
final favorites = FavoriteController(
InMemoryFavoriteRepository(
saved: {'with-synopsis', 'without-synopsis'},
cards: [
_card('with-synopsis', synopsis: longSynopsis),
_card('without-synopsis', synopsis: ' '),
],
),
);
await _pumpPage(tester, auth: auth, favorites: favorites);
final withSynopsis = tester.widget<Text>(
find.byKey(const Key('favorite-synopsis-with-synopsis')),
);
expect(withSynopsis.maxLines, 2);
expect(withSynopsis.overflow, TextOverflow.ellipsis);
expect(
find.byKey(const Key('favorite-synopsis-without-synopsis')),
findsNothing,
);
expect(
tester.getSize(find.byKey(const Key('favorite-card-with-synopsis'))),
tester.getSize(find.byKey(const Key('favorite-card-without-synopsis'))),
);
});
testWidgets('should_keep_card_and_show_failure_when_remove_fails', (
tester,
) async {
@@ -137,7 +177,7 @@ void main() {
testWidgets('should_show_retry_when_favorite_list_fails', (tester) async {
final auth = await _auth(signedIn: true);
final repository = InMemoryFavoriteRepository()..failList = true;
final repository = InMemoryFavoriteRepository()..failPage = true;
final favorites = FavoriteController(repository);
await _pumpPage(tester, auth: auth, favorites: favorites);
@@ -159,7 +199,7 @@ void main() {
await _pumpPage(tester, auth: auth, favorites: favorites);
expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget);
expect(repository.listCalls, 0);
expect(repository.pageCalls, 0);
},
);
+104
View File
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/library_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
test('should_request_favorites_with_page_and_parse_page_result', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {
'total': '21',
'list': [
{'id': '9', 'title': '收藏剧', 'cover': '/files/9.png'},
],
},
}),
),
);
final api = LibraryApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.favorites(pageIndex: 2, pageSize: 10);
final request = adapter.requests.single;
expect(request.uri.path, '/app/favorites');
expect(request.queryParameters['pageIndex'], '2');
expect(request.queryParameters['pageSize'], '10');
expect(page.total, 21);
expect(page.list.single.id, '9');
expect(page.list.single.title, '收藏剧');
});
test('should_parse_favorites_when_server_returns_legacy_list', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': [
{'id': '5', 'title': '闪婚后', 'cover': '/files/5.png'},
{'id': '4', 'title': '另一部', 'cover': '/files/4.png'},
],
}),
),
);
final api = LibraryApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.favorites();
expect(page.total, 2);
expect(page.list.map((drama) => drama.id), ['5', '4']);
expect(page.list.first.title, '闪婚后');
});
test('should_return_empty_favorites_when_data_is_null', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
),
);
final api = LibraryApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.favorites();
expect(page.total, 0);
expect(page.list, isEmpty);
});
test('should_throw_api_exception_when_favorites_business_code_failed', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
),
);
final api = LibraryApi(
ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
// 无刷新回调时,401 直接抛业务异常,避免重试逻辑。
tokenRefresher: null,
),
);
await expectLater(
() => api.favorites(),
throwsA(
isA<ApiException>().having((e) => e.code, 'code', 401),
),
);
});
}
@@ -0,0 +1,105 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
void main() {
test('should_clamp_invalid_or_out_of_range_position_when_caching', () {
expect(
clampPlaybackPosition(
const Duration(seconds: -1),
const Duration(seconds: 10),
),
Duration.zero,
);
expect(
clampPlaybackPosition(
const Duration(seconds: 12),
const Duration(seconds: 10),
),
const Duration(seconds: 10),
);
expect(
clampPlaybackPosition(
const Duration(seconds: 5),
const Duration(seconds: 10),
),
const Duration(seconds: 5),
);
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'episode-3',
dramaId: 'drama-a',
position: const Duration(minutes: 4),
duration: const Duration(minutes: 3),
);
expect(
cache.read('episode-3', dramaId: 'drama-a')?.position,
const Duration(minutes: 3),
);
});
test('should_store_and_read_recent_position_when_drama_matches', () {
final now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now);
cache.save(
episodeId: 'episode-3',
dramaId: 'drama-a',
position: const Duration(minutes: 1, seconds: 20),
duration: const Duration(minutes: 3),
);
final entry = cache.read('episode-3', dramaId: 'drama-a');
expect(entry, isNotNull);
expect(entry!.position, const Duration(minutes: 1, seconds: 20));
expect(entry.duration, const Duration(minutes: 3));
expect(entry.updatedAt, now);
});
test('should_reject_cached_position_when_drama_does_not_match', () {
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'episode-3',
dramaId: 'drama-a',
position: const Duration(seconds: 80),
duration: const Duration(minutes: 3),
);
expect(cache.read('episode-3', dramaId: 'drama-b'), isNull);
});
test('should_use_latest_saved_episode_when_timestamps_match', () {
final now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now);
cache.save(
episodeId: 'episode-1',
dramaId: 'drama-a',
position: const Duration(seconds: 30),
duration: const Duration(minutes: 3),
);
cache.save(
episodeId: 'episode-2',
dramaId: 'drama-a',
position: const Duration(seconds: 45),
duration: const Duration(minutes: 3),
);
expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2');
});
test('should_reject_expired_cached_position', () {
var now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now);
cache.save(
episodeId: 'episode-3',
dramaId: 'drama-a',
position: const Duration(seconds: 80),
duration: const Duration(minutes: 3),
);
now = now.add(const Duration(days: 1));
expect(cache.read('episode-3', dramaId: 'drama-a'), isNull);
});
}
@@ -0,0 +1,227 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
void main() {
test('should_bucket_thumbnail_requests_by_whole_seconds', () {
expect(playbackThumbnailBucket(Duration.zero), 0);
expect(playbackThumbnailBucket(const Duration(milliseconds: 1999)), 1);
expect(playbackThumbnailBucket(const Duration(seconds: 12)), 12);
expect(playbackThumbnailBucket(const Duration(milliseconds: -300)), 0);
});
test('should_return_injected_frame_from_loader', () async {
final loader = _RecordingLoader((_) async => ByteData(1));
final frame = await loader.load(
const PlaybackThumbnailFrameRequest(
episodeId: 'episode',
position: Duration(seconds: 1),
source: PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
),
);
expect(frame, isNotNull);
});
test('should_evict_least_recently_used_frame_after_24_entries', () async {
final loader = _RecordingLoader((_) async => ByteData(1));
final service = _service(loader);
addTearDown(service.dispose);
for (var second = 0; second < 25; second++) {
final frame = await service.thumbnail(
'episode',
Duration(seconds: second),
);
expect(
frame,
isNotNull,
reason: 'bucket $second loader=${loader.buckets}',
);
}
expect(service.cacheSize, 24);
expect(loader.buckets.first, 0);
await service.thumbnail('episode', const Duration(seconds: 1));
expect(loader.buckets.where((bucket) => bucket == 1), hasLength(1));
await service.thumbnail('episode', Duration.zero);
expect(loader.buckets.where((bucket) => bucket == 0), hasLength(2));
});
test('should_coalesce_same_bucket_while_frame_is_in_flight', () async {
final frame = Completer<ByteData?>();
final loader = _RecordingLoader((_) => frame.future);
final service = _service(loader);
addTearDown(service.dispose);
final first = service.thumbnail('episode', const Duration(seconds: 8));
await Future<void>.delayed(Duration.zero);
final second = service.thumbnail(
'episode',
const Duration(milliseconds: 8999),
);
expect(identical(first, second), isTrue);
expect(loader.buckets, [8]);
frame.complete(ByteData(2));
expect(await first, isNotNull);
expect(await second, isNotNull);
});
test('should_drop_superseded_request_and_start_latest_bucket', () async {
final frames = <int, Completer<ByteData?>>{};
final loader = _RecordingLoader((request) {
final completer = Completer<ByteData?>();
frames[request.position.inSeconds] = completer;
return completer.future;
});
final service = _service(loader);
addTearDown(service.dispose);
final stale = service.thumbnail('episode', const Duration(seconds: 4));
await Future<void>.delayed(Duration.zero);
final latest = service.thumbnail('episode', const Duration(seconds: 9));
await Future<void>.delayed(Duration.zero);
expect(loader.buckets, [4]);
frames[4]!.complete(ByteData(4));
expect(await stale, isNull);
await Future<void>.delayed(Duration.zero);
expect(loader.buckets, [4, 9]);
frames[9]!.complete(ByteData(9));
expect(await latest, isNotNull);
});
test('should_cancel_unstarted_request_when_scrub_ends', () async {
final firstFrame = Completer<ByteData?>();
final loader = _RecordingLoader((request) {
if (request.position.inSeconds == 3) return firstFrame.future;
return Future<ByteData?>.value(ByteData(7));
});
final service = _service(loader);
addTearDown(service.dispose);
final active = service.thumbnail('episode', const Duration(seconds: 3));
await Future<void>.delayed(Duration.zero);
final pending = service.thumbnail('episode', const Duration(seconds: 7));
service.cancelPending();
expect(await pending, isNull);
firstFrame.complete(ByteData(3));
expect(await active, isNull);
await Future<void>.delayed(Duration.zero);
expect(loader.buckets, [3]);
});
test('should_prefetch_one_neighbor_after_scrub_pause', () async {
final loader = _RecordingLoader((_) async => ByteData(1));
final service = _service(
loader,
prefetchDelay: const Duration(milliseconds: 10),
);
addTearDown(service.dispose);
await service.thumbnail('episode', const Duration(seconds: 5));
expect(loader.buckets, [5]);
await Future<void>.delayed(const Duration(milliseconds: 5));
expect(loader.buckets, [5]);
await Future<void>.delayed(const Duration(milliseconds: 12));
expect(loader.buckets, [5, 6]);
});
test('should_not_prefetch_over_newer_unstarted_bucket', () async {
final frames = <int, Completer<ByteData?>>{};
final loader = _RecordingLoader((request) {
final completer = Completer<ByteData?>();
frames[request.position.inSeconds] = completer;
return completer.future;
});
final service = _service(
loader,
prefetchDelay: const Duration(milliseconds: 10),
);
addTearDown(service.dispose);
final first = service.thumbnail('episode', const Duration(seconds: 1));
await Future<void>.delayed(Duration.zero);
final latest = service.thumbnail('episode', const Duration(seconds: 3));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(loader.buckets, [1]);
frames[1]!.complete(ByteData(1));
expect(await first, isNull);
await Future<void>.delayed(Duration.zero);
expect(loader.buckets, [1, 3]);
frames[3]!.complete(ByteData(3));
expect(await latest, isNotNull);
});
test('should_return_null_once_then_negative_cache_failed_bucket', () async {
final loader = _RecordingLoader((_) => Completer<ByteData?>().future);
final service = _service(
loader,
requestTimeout: const Duration(milliseconds: 10),
);
addTearDown(service.dispose);
expect(
await service.thumbnail('episode', const Duration(seconds: 2)),
isNull,
);
expect(
await service.thumbnail('episode', const Duration(seconds: 2)),
isNull,
);
expect(loader.buckets, [2]);
});
test('should_convert_loader_exception_to_null', () async {
final loader = _RecordingLoader((_) async => throw StateError('decode'));
final service = _service(loader);
addTearDown(service.dispose);
expect(
await service.thumbnail('episode', const Duration(seconds: 6)),
isNull,
);
});
}
PlaybackThumbnailService _service(
PlaybackThumbnailFrameLoader loader, {
Duration requestTimeout = const Duration(hours: 1),
Duration prefetchDelay = const Duration(hours: 1),
}) {
return PlaybackThumbnailService(
frameLoader: loader,
sourceResolver: (_) =>
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
requestTimeout: requestTimeout,
prefetchDelay: prefetchDelay,
);
}
class _RecordingLoader implements PlaybackThumbnailFrameLoader {
_RecordingLoader(this._handler);
final Future<ByteData?> Function(PlaybackThumbnailFrameRequest request)
_handler;
final List<int> buckets = [];
@override
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) {
buckets.add(request.position.inSeconds);
return _handler(request);
}
@override
void dispose() {}
}
+94 -3
View File
@@ -1,8 +1,12 @@
// Author: fruit.com.cn
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart';
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
import 'package:video_player/video_player.dart';
@@ -17,21 +21,38 @@ void main() {
expect(seekPositionForFraction(1, duration), lessThan(duration));
});
testWidgets('should_show_loading_track_when_controller_is_missing', (
test('should_clamp_preview_card_inside_track_safe_margins', () {
expect(
scrubPreviewLeft(trackWidth: 300, fraction: 0),
scrubPreviewSafeMargin,
);
expect(
scrubPreviewLeft(trackWidth: 300, fraction: 1),
300 - scrubPreviewWidth - scrubPreviewSafeMargin,
);
expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0);
});
testWidgets('should_show_inactive_track_when_video_is_not_initialized', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue.uninitialized(),
);
addTearDown(video.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SeekProgressBar(
valueListenable: null,
valueListenable: video,
semanticsLabel: 'Seek video',
),
),
),
);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
});
@@ -228,6 +249,59 @@ void main() {
},
);
testWidgets('should_show_frame_card_with_cover_then_hide_on_release', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 3),
isInitialized: true,
),
);
final service = _thumbnailService();
addTearDown(video.dispose);
addTearDown(service.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 300,
child: SeekProgressBar(
valueListenable: video,
semanticsLabel: 'Seek video',
episodeId: 'episode-1',
posterUrl: '',
thumbnailService: service,
onSeek: (_) async {},
),
),
),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Slider)),
);
await gesture.moveBy(const Offset(80, 0));
await tester.pump();
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-frame')), findsNothing);
expect(find.textContaining('/ 00:12'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('scrub-preview-card'))).width,
120,
);
await gesture.up();
await tester.pump();
expect(find.byKey(const Key('scrub-preview-card')), findsNothing);
});
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
tester,
) async {
@@ -262,3 +336,20 @@ void main() {
expect(find.textContaining('00:12'), findsNothing);
});
}
PlaybackThumbnailService _thumbnailService() {
return PlaybackThumbnailService(
frameLoader: _NullThumbnailLoader(),
sourceResolver: (_) =>
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
prefetchDelay: const Duration(hours: 1),
);
}
class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader {
@override
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async => null;
@override
void dispose() {}
}
@@ -0,0 +1,233 @@
// Author: fruit.com.cn
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart';
import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart';
const String _spriteVtt = '''
WEBVTT
00:00:00.000 --> 00:00:01.000
sprite_001.jpg#xywh=0,0,160,90
00:00:01.000 --> 00:00:02.000
sprite_001.jpg#xywh=160,0,160,90
00:00:02.000 --> 00:00:03.000
sprite_002.jpg#xywh=0,0,160,90
00:00:03.000 --> 00:00:04.000
sprite_003.jpg#xywh=0,0,160,90
''';
void main() {
test('should_parse_cue_times_xywh_and_multiple_sprite_files', () {
final axis = parseSpriteThumbnailVtt(
_spriteVtt,
vttUrl: 'https://cdn.test/transcoded/thumbnails.vtt?e=1&s=x',
);
expect(axis.cues, hasLength(4));
expect(axis.cues.first.start, Duration.zero);
expect(axis.cues.first.end, const Duration(seconds: 1));
expect(axis.cues.first.spriteFile, 'sprite_001.jpg');
expect(axis.cues.first.sourceRect, const Rect.fromLTWH(0, 0, 160, 90));
expect(axis.cues[1].sourceRect, const Rect.fromLTWH(160, 0, 160, 90));
expect(axis.cues[2].spriteFile, 'sprite_002.jpg');
expect(axis.cues[3].spriteFile, 'sprite_003.jpg');
expect(
axis.resolveSpriteUri(axis.cues[2].spriteFile).toString(),
'https://cdn.test/transcoded/sprite_002.jpg',
);
});
test('should_select_cue_and_use_nearest_when_position_is_out_of_range', () {
final cues = parseSpriteThumbnailVtt(
_spriteVtt,
vttUrl: 'https://cdn.test/thumbnails.vtt',
).cues;
expect(
selectSpriteThumbnailCue(cues, const Duration(milliseconds: 1500)),
same(cues[1]),
);
expect(
selectSpriteThumbnailCue(cues, const Duration(seconds: -1)),
same(cues[0]),
);
expect(
selectSpriteThumbnailCue(cues, const Duration(seconds: 9)),
same(cues[3]),
);
});
test('should_return_empty_axis_when_vtt_cannot_be_parsed', () {
final axis = parseSpriteThumbnailVtt(
'not-a-valid-vtt',
vttUrl: 'https://cdn.test/broken.vtt',
);
expect(axis.cues, isEmpty);
});
testWidgets('should_keep_only_two_decoded_sprites_in_lru', (tester) async {
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
final service = SpriteThumbnailAxisService(
loader: loader,
cacheTtl: const Duration(minutes: 10),
);
addTearDown(service.dispose);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(service.spriteCacheSize, 2);
expect(
service.spriteCacheKeys,
containsAll(['sprite_001.jpg', 'sprite_002.jpg']),
);
await service.prepareAt(
'https://cdn.test/thumbnails.vtt',
const Duration(milliseconds: 2500),
);
expect(service.spriteCacheSize, 2);
expect(service.spriteCacheKeys, contains('sprite_002.jpg'));
expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg')));
});
testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async {
final loader = _FakeSpriteLoader(
vttResponses: const [SpriteThumbnailForbiddenException(), _spriteVtt],
);
final service = SpriteThumbnailAxisService(loader: loader);
addTearDown(service.dispose);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(loader.vttCalls, 2);
expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue);
});
testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async {
var now = DateTime(2026, 10, 22);
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
final service = SpriteThumbnailAxisService(loader: loader, now: () => now);
addTearDown(service.dispose);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
now = now.add(const Duration(minutes: 11));
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(loader.vttCalls, 2);
});
test('should_resolve_fallback_chain_in_priority_order', () {
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: true,
hasSpriteFrame: true,
hasAxis: true,
hasLocalFrame: true,
),
ScrubPreviewStage.sprite,
);
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: true,
hasSpriteFrame: false,
hasAxis: false,
hasLocalFrame: true,
),
ScrubPreviewStage.waitingForAxis,
);
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: false,
hasSpriteFrame: false,
hasAxis: false,
hasLocalFrame: true,
),
ScrubPreviewStage.localFrame,
);
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: true,
hasSpriteFrame: false,
hasAxis: true,
hasLocalFrame: false,
),
ScrubPreviewStage.cover,
);
});
test('should_parse_thumbnails_url_from_episode_and_ticket', () {
final episode = Episode.fromJson(const {
'id': '11',
'number': 1,
'title': 'episode',
'isFree': true,
'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x',
});
final ticket = PlayTicket.fromJson(const {
'episodeId': '11',
'accessible': true,
'accessType': 'FREE',
'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x',
});
expect(episode.thumbnailsUrl, ticket.thumbnailsUrl);
expect(
episode.withVideoUrl('file:///video.mp4').thumbnailsUrl,
episode.thumbnailsUrl,
);
});
}
class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
_FakeSpriteLoader({this.vtt, this.vttResponses});
final String? vtt;
final List<Object>? vttResponses;
int vttCalls = 0;
final List<String> spriteCalls = [];
@override
Future<String?> loadVtt(Uri uri) async {
final index = vttCalls++;
final responses = vttResponses;
if (responses == null) return vtt;
final response = responses[index.clamp(0, responses.length - 1)];
if (response is SpriteThumbnailForbiddenException) throw response;
return response as String;
}
@override
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
spriteCalls.add(uri.pathSegments.last);
final image = await _createImage(320, 180);
return SpriteThumbnailDecodedImage(
image: image,
originalWidth: 320,
originalHeight: 180,
);
}
}
Future<ui.Image> _createImage(int width, int height) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
Paint()..color = Colors.blue,
);
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
picture.dispose();
return image;
}
+10 -9
View File
@@ -99,13 +99,8 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final sessionLoading = find.byKey(const Key('player-session-loading'));
expect(sessionLoading, findsOneWidget);
expect(tester.getSize(sessionLoading), const Size(24, 24));
expect(
tester.widget<CircularProgressIndicator>(sessionLoading).strokeWidth,
2,
);
expect(find.byKey(const Key('player-session-loading')), findsNothing);
expect(find.byKey(const Key('progress-loading')), findsNothing);
initialization.complete();
await tester.pumpWidget(const SizedBox.shrink());
@@ -127,6 +122,8 @@ void main() {
await _openSeriesPlayer(tester);
final player = players.firstWhere((item) => item.playing);
expect(player.playing, isTrue);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
final navigator = tester.state<NavigatorState>(
find.byType(Navigator).first,
@@ -147,6 +144,8 @@ void main() {
await tester.pumpAndSettle();
expect(player.playing, isTrue);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
});
testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', (
@@ -571,7 +570,8 @@ void main() {
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))),
const Size(40, 40),
);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(bottom.height, lessThanOrEqualTo(200));
@@ -1301,7 +1301,8 @@ void _expectFullscreenOverlay(WidgetTester tester) {
expect(find.byKey(const Key('player-pip')), findsNothing);
expect(find.byKey(const Key('player-rail-comments')), findsOneWidget);
expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(find.byKey(const Key('player-title')), findsNothing);
expect(find.byKey(const Key('player-subtitle')), findsNothing);
+237 -4
View File
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:video_player/video_player.dart';
// ignore: depend_on_referenced_packages
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
@@ -12,6 +15,7 @@ import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/download_storage.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/core/data/services/video_player_port.dart';
import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
@@ -48,11 +52,13 @@ AndroidPictureInPicture _pip() =>
PlayerLogic _logic(
FakeWatchHistory history, {
PlaybackRepository? playback,
PlaybackPositionCache? positionCache,
Future<void> Function()? waitForFrame,
}) => PlayerLogic(
history: history,
playback: playback ?? FakePlaybackRepository(),
pictureInPicture: _pip(),
positionCache: positionCache,
waitForFrame: waitForFrame ?? () async {},
);
@@ -126,6 +132,203 @@ void main() {
});
});
group('playback position cache', () {
test('should_use_cached_resume_point_without_reading_history', () async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 12);
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'signal-1',
dramaId: 'signal',
position: const Duration(minutes: 1, seconds: 20),
duration: const Duration(minutes: 3),
);
final logic = _logic(history, positionCache: cache);
logic.attach(
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
isMounted: () => true,
);
logic.start(
factory: _RecordingFactory().create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
await pumpEventQueue();
expect(logic.resumePoint?.position, const Duration(seconds: 80));
expect(history.reads, isEmpty);
logic.dispose();
await _drain(logic);
});
test('should_fall_back_to_server_when_cache_belongs_to_other_drama',
() async {
final history = FakeWatchHistory()
..records['signal'] = const WatchProgress(
dramaId: 'signal',
episodeId: 'signal-2',
position: Duration(seconds: 42),
duration: Duration(minutes: 3),
);
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'signal-1',
dramaId: 'other-drama',
position: const Duration(seconds: 80),
duration: const Duration(minutes: 3),
);
final logic = _logic(history, positionCache: cache);
logic.attach(
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
isMounted: () => true,
);
logic.start(
factory: _RecordingFactory().create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
await pumpEventQueue();
expect(logic.resumePoint?.episodeId, 'signal-2');
expect(logic.resumePoint?.position, const Duration(seconds: 42));
expect(history.reads, ['signal']);
logic.dispose();
await _drain(logic);
});
test('should_fall_back_to_server_when_cache_is_missing', () async {
final history = FakeWatchHistory()
..records['signal'] = const WatchProgress(
dramaId: 'signal',
episodeId: 'signal-1',
position: Duration(seconds: 36),
duration: Duration(minutes: 3),
);
final logic = _logic(history);
logic.attach(
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
isMounted: () => true,
);
logic.start(
factory: _RecordingFactory().create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
await pumpEventQueue();
expect(logic.resumePoint?.position, const Duration(seconds: 36));
expect(history.reads, ['signal']);
logic.dispose();
await _drain(logic);
});
testWidgets(
'should_cache_actual_position_after_seek_completes',
(tester) async {
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (pageContext) {
context = pageContext;
return const Scaffold(body: SizedBox.expand());
},
),
),
);
final cache = PlaybackPositionCache();
final history = FakeWatchHistory();
final factory = _VideoRecordingFactory();
late PlayerLogic logic;
await tester.runAsync(() async {
logic = _logic(history, positionCache: cache);
logic.attach(
PlayerPage(drama: _drama('signal'), initialIndex: 0),
isMounted: () => true,
);
logic.start(
factory: factory.create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
});
final player = logic.session.active! as _VideoRecordingPlayer;
logic.beginScrub(player);
await tester.runAsync(
() => logic.seekTo(
context,
player,
const Duration(seconds: 42),
),
);
await tester.runAsync(() => logic.pendingWrite);
expect(
cache.read('signal-1', dramaId: 'signal')?.position,
const Duration(seconds: 42),
);
expect(history.writes.last, 'signal-1|42000|false');
logic.beginScrub(player);
await tester.runAsync(
() => logic.seekTo(
context,
player,
const Duration(minutes: 2),
),
);
await tester.runAsync(() => logic.pendingWrite);
expect(player.controller.value.position, const Duration(minutes: 1));
expect(
cache.read('signal-1', dramaId: 'signal')?.position,
const Duration(minutes: 1),
);
expect(history.writes.last, 'signal-1|60000|false');
logic.dispose();
await tester.pump();
},
);
testWidgets('should_seek_cached_position_before_play_when_rebuilding', (
tester,
) async {
final previousPlatform = VideoPlayerPlatform.instance;
VideoPlayerPlatform.instance = _VideoPlayerPlatformStub();
addTearDown(() => VideoPlayerPlatform.instance = previousPlatform);
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'signal-1',
dramaId: 'signal',
position: const Duration(seconds: 42),
duration: const Duration(minutes: 1),
);
final factory = _VideoRecordingFactory();
late PlayerLogic logic;
await tester.runAsync(() async {
logic = _logic(FakeWatchHistory(), positionCache: cache);
logic.attach(
PlayerPage(drama: _drama('signal'), initialIndex: 0),
isMounted: () => true,
);
logic.start(
factory: factory.create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
await pumpEventQueue();
await logic.session.playbackSettled;
await logic.session.preloadSettled;
});
final player = logic.session.active! as _VideoRecordingPlayer;
expect(player.events, containsAllInOrder(['initialize', 'seek', 'play']));
expect(player.controller.value.position, const Duration(seconds: 42));
logic.dispose();
await tester.pump();
});
});
test('should_play_local_file_without_ticket_when_episode_is_downloaded',
() async {
final playback = FakePlaybackRepository();
@@ -1134,21 +1337,38 @@ class _SessionVideoSurface extends StatelessWidget {
class _VideoRecordingFactory {
final List<_VideoRecordingPlayer> created = [];
final List<String> events = [];
PlayerPort create(Episode episode) {
final player = _VideoRecordingPlayer(episode);
final player = _VideoRecordingPlayer(episode, events);
created.add(player);
return player;
}
}
class _VideoPlayerPlatformStub extends VideoPlayerPlatform {
@override
Future<void> init() async {}
@override
Future<void> dispose(int playerId) async {}
@override
Future<void> setVolume(int playerId, double volume) async {}
@override
Future<void> setPlaybackSpeed(int playerId, double speed) async {}
}
class _VideoRecordingPlayer extends VideoPlayerPort {
_VideoRecordingPlayer(super.episode);
_VideoRecordingPlayer(super.episode, this.events);
bool disposed = false;
final List<String> events;
@override
Future<void> initialize() async {
events.add('initialize');
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
@@ -1158,10 +1378,23 @@ class _VideoRecordingPlayer extends VideoPlayerPort {
}
@override
Future<void> play() async {}
Future<void> play() async => events.add('play');
@override
Future<void> pause() async {}
Future<void> pause() async => events.add('pause');
@override
Future<void> seekTo(Duration position) async {
events.add('seek');
final current = controller.value;
controller.value = VideoPlayerValue(
duration: current.duration,
size: current.size,
position: position,
isInitialized: current.isInitialized,
isPlaying: current.isPlaying,
);
}
@override
Future<void> dispose() async {
+80 -27
View File
@@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/video_player_port.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
@@ -35,17 +36,19 @@ void main() {
final history = FakeWatchHistory();
final playback = FakePlaybackRepository();
final catalog = CatalogController(FakeCatalogRepository(dramas: [drama]));
final positionCache = PlaybackPositionCache();
final favorites = FavoriteController(
InMemoryFavoriteRepository(saved: {drama.id}, cards: [drama]),
);
final logic = PlayerLogic(
history: history,
playback: playback,
positionCache: positionCache,
pictureInPicture: AndroidPictureInPicture(
channel: const MethodChannel('test/player_open_detail/pip'),
),
);
final factory = _RecordingFactory();
final factory = _VideoRecordingFactory();
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
@@ -56,15 +59,9 @@ void main() {
final active = logic.session.active;
final index = logic.session.currentIndex;
final player = factory.created.first;
final playsBefore = player.plays;
final tracked = VideoPlayerPort(drama.episodes.first);
tracked.controller.value = const VideoPlayerValue(
duration: Duration(minutes: 5),
position: Duration(seconds: 37),
isInitialized: true,
);
final createdBeforeDetail = factory.created.length;
logic
..trackedPlayer = tracked
..trackedPlayer = player
..trackedEpisode = drama.episodes.first
..trackedDrama = drama;
@@ -74,6 +71,7 @@ void main() {
providers: [
Provider<WatchHistoryRepository>.value(value: history),
Provider<PlaybackRepository>.value(value: playback),
Provider<PlaybackPositionCache>.value(value: positionCache),
ChangeNotifierProvider<CatalogController>.value(value: catalog),
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
Provider<Haptics>.value(value: FakeHaptics()),
@@ -92,19 +90,33 @@ void main() {
),
);
logic.beginScrub(player);
await logic.seekTo(
pageContext,
player,
const Duration(minutes: 1, seconds: 22),
);
unawaited(logic.openDetail(pageContext));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await _pumpSession(tester);
await tester.runAsync(() => logic.session.playbackSettled);
expect(find.byType(DramaDetailPage), findsOneWidget);
expect(logic.session.active, same(active));
expect(logic.session.currentIndex, index);
expect(logic.session.userWantsPlay, isTrue);
expect(player.pauses, 1);
expect(player.plays, playsBefore);
expect(tracked.controller.value.position, const Duration(seconds: 37));
expect(history.writes, isEmpty);
expect(factory.created, hasLength(createdBeforeDetail));
expect(
player.controller.value.position,
const Duration(minutes: 1, seconds: 22),
);
expect(history.writes, ['signal-1|82000|false', 'signal-1|82000|false']);
expect(
positionCache.read('signal-1', dramaId: 'signal')?.position,
const Duration(minutes: 1, seconds: 22),
);
await tester.pageBack();
await tester.pump();
@@ -115,9 +127,16 @@ void main() {
expect(logic.session.active, same(active));
expect(logic.session.currentIndex, index);
expect(logic.session.userWantsPlay, isTrue);
expect(player.plays, playsBefore + 1);
expect(tracked.controller.value.position, const Duration(seconds: 37));
expect(history.writes, ['signal-1|37000|false']);
expect(factory.created, hasLength(createdBeforeDetail));
expect(
player.controller.value.position,
const Duration(minutes: 1, seconds: 22),
);
expect(history.writes, [
'signal-1|82000|false',
'signal-1|82000|false',
'signal-1|82000|false',
]);
logic
..trackedPlayer = null
@@ -125,7 +144,6 @@ void main() {
..trackedDrama = null
..dispose();
await _pumpSession(tester);
await tracked.dispose();
},
);
}
@@ -153,29 +171,64 @@ Future<void> _pumpSession(WidgetTester tester) async {
}
}
class _RecordingFactory {
final List<_RecordingPlayer> created = [];
class _VideoRecordingFactory {
final List<_VideoRecordingPlayer> created = [];
PlayerPort create(Episode episode) {
final player = _RecordingPlayer();
final player = _VideoRecordingPlayer(episode);
created.add(player);
return player;
}
}
class _RecordingPlayer implements PlayerPort {
int plays = 0;
int pauses = 0;
class _VideoRecordingPlayer extends VideoPlayerPort {
_VideoRecordingPlayer(super.episode);
@override
Future<void> initialize() async {}
Future<void> initialize() async {
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 5),
size: Size(1280, 720),
position: Duration(seconds: 1),
isInitialized: true,
);
}
@override
Future<void> play() async => plays++;
Future<void> play() async {
_setPlaying(true);
}
@override
Future<void> pause() async => pauses++;
Future<void> pause() async {
_setPlaying(false);
}
@override
Future<void> dispose() async {}
Future<void> seekTo(Duration position) async {
final current = controller.value;
controller.value = VideoPlayerValue(
duration: current.duration,
size: current.size,
position: position,
isInitialized: current.isInitialized,
isPlaying: current.isPlaying,
);
}
void _setPlaying(bool playing) {
final current = controller.value;
controller.value = VideoPlayerValue(
duration: current.duration,
size: current.size,
position: current.position,
isInitialized: current.isInitialized,
isPlaying: playing,
);
}
@override
Future<void> dispose() async {
await controller.dispose();
}
}
+171
View File
@@ -0,0 +1,171 @@
// Author: fruit.com.cn
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart';
import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart';
void main() {
testWidgets('should_render_sprite_crop_without_requesting_local_frame', (
tester,
) async {
final image = await _createImage(320, 180);
final cue = const SpriteThumbnailCue(
start: Duration.zero,
end: Duration(seconds: 2),
spriteFile: 'sprite_001.jpg',
sourceRect: Rect.fromLTWH(0, 0, 160, 90),
);
final axis = _FakeAxisSource(
axisReady: true,
frame: SpriteThumbnailFrame(
image: image,
sourceRect: const Rect.fromLTWH(0, 0, 160, 90),
spriteUrl: 'https://cdn.test/sprite_001.jpg',
cue: cue,
),
);
final loader = _CountingThumbnailLoader();
final localService = _localService(loader);
addTearDown(image.dispose);
addTearDown(axis.dispose);
addTearDown(localService.dispose);
await tester.pumpWidget(
_card(
localService: localService,
axis: axis,
thumbnailsUrl: 'https://cdn.test/thumbnails.vtt',
),
);
await tester.pump();
final preview = tester.widget<SpriteCropPreview>(
find.byKey(const Key('scrub-preview-sprite')),
);
expect(preview.sourceRect, const Rect.fromLTWH(0, 0, 160, 90));
expect(find.byKey(const Key('scrub-preview-frame')), findsNothing);
expect(loader.calls, 0);
});
testWidgets('should_fallback_to_local_frame_when_thumbnails_url_is_missing', (
tester,
) async {
final loader = _CountingThumbnailLoader();
final localService = _localService(loader);
final axis = _FakeAxisSource(axisReady: false);
addTearDown(axis.dispose);
addTearDown(localService.dispose);
await tester.pumpWidget(
_card(localService: localService, axis: axis, thumbnailsUrl: null),
);
await tester.pump();
expect(loader.calls, 1);
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing);
});
testWidgets('should_keep_poster_when_axis_and_local_frame_both_fail', (
tester,
) async {
final loader = _CountingThumbnailLoader();
final localService = _localService(loader);
final axis = _FakeAxisSource(axisReady: true);
addTearDown(axis.dispose);
addTearDown(localService.dispose);
await tester.pumpWidget(
_card(
localService: localService,
axis: axis,
thumbnailsUrl: 'https://cdn.test/empty.vtt',
),
);
await tester.pump();
expect(loader.calls, 1);
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing);
expect(find.byKey(const Key('scrub-preview-frame')), findsNothing);
});
}
Widget _card({
required PlaybackThumbnailService localService,
required SpriteThumbnailAxisSource axis,
required String? thumbnailsUrl,
}) {
return MaterialApp(
home: Scaffold(
body: Center(
child: ScrubPreviewCard(
service: localService,
spriteAxisService: axis,
thumbnailsUrl: thumbnailsUrl,
episodeId: 'episode-1',
posterUrl: '',
position: const Duration(seconds: 1),
duration: const Duration(seconds: 10),
),
),
),
);
}
PlaybackThumbnailService _localService(PlaybackThumbnailFrameLoader loader) {
return PlaybackThumbnailService(
frameLoader: loader,
sourceResolver: (_) =>
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
prefetchDelay: const Duration(hours: 1),
);
}
class _FakeAxisSource extends ChangeNotifier
implements SpriteThumbnailAxisSource {
_FakeAxisSource({required this.axisReady, this.frame});
final bool axisReady;
final SpriteThumbnailFrame? frame;
@override
SpriteThumbnailFrame? cachedFrameAt(
String? thumbnailsUrl,
Duration position,
) => frame;
@override
bool hasAxis(String? thumbnailsUrl) => axisReady;
}
class _CountingThumbnailLoader implements PlaybackThumbnailFrameLoader {
int calls = 0;
@override
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async {
calls++;
return null;
}
@override
void dispose() {}
}
Future<ui.Image> _createImage(int width, int height) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
Paint()..color = Colors.orange,
);
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
picture.dispose();
return image;
}
+15 -2
View File
@@ -1,4 +1,7 @@
// Author: fruit.com.cn
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/models/page_result.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
/// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。
@@ -57,9 +60,19 @@ class FakeWatchHistory implements WatchHistoryRepository {
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
if (failReads) throw StateError('history unavailable');
return recentRecords.take(limit).toList();
final offset = (pageIndex - 1) * pageSize;
if (offset >= recentRecords.length) {
return PageResult(total: recentRecords.length);
}
return PageResult(
total: recentRecords.length,
list: recentRecords.skip(offset).take(pageSize).toList(),
);
}
@override
+101
View File
@@ -0,0 +1,101 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/watch_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
test('should_request_recent_with_page_and_parse_display_fields', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {
'total': '18',
'list': [
{
'dramaId': '12',
'episodeId': '88',
'dramaTitle': '剧名',
'dramaCover': '/files/12.png',
'episodeNumber': 6,
'totalEpisodes': 68,
'positionMs': '80000',
'durationMs': '120000',
},
],
},
}),
),
);
final api = WatchApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.recent(pageIndex: 2, pageSize: 10);
final request = adapter.requests.single;
expect(request.uri.path, '/app/watch/recent');
expect(request.queryParameters['pageIndex'], '2');
expect(request.queryParameters['pageSize'], '10');
expect(request.queryParameters.containsKey('limit'), isFalse);
expect(page.total, 18);
expect(page.list.single.dramaTitle, '剧名');
expect(page.list.single.dramaCover, '/files/12.png');
expect(page.list.single.episodeNumber, 6);
expect(page.list.single.totalEpisodes, 68);
});
test('should_parse_recent_when_server_returns_legacy_list', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': [
{
'dramaId': '5',
'episodeId': '7',
'positionMs': '203904',
'durationMs': '227882',
'finished': false,
'playCount': 8,
'updatedAt': '2026-10-21T15:17:05.419',
},
],
}),
),
);
final api = WatchApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.recent();
expect(page.total, 1);
expect(page.list.single.dramaId, '5');
expect(page.list.single.episodeId, '7');
expect(page.list.single.position.inMilliseconds, 203904);
});
test('should_return_empty_recent_when_data_is_null', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
),
);
final api = WatchApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.recent();
expect(page.total, 0);
expect(page.list, isEmpty);
});
}
+124 -2
View File
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/page_result.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
@@ -15,11 +16,13 @@ import 'package:fruitshorts/core/data/services/watch_records_controller.dart';
WatchProgress _progress(
String dramaId, {
String episodeId = 'episode-1',
String? dramaTitle,
int positionMs = 12000,
int durationMs = 60000,
}) => WatchProgress(
dramaId: dramaId,
episodeId: episodeId,
dramaTitle: dramaTitle,
position: Duration(milliseconds: positionMs),
duration: Duration(milliseconds: durationMs),
updatedAt: DateTime(2026, 10, 17, 12),
@@ -73,6 +76,27 @@ void main() {
expect(controller.error, isNull);
});
test('should_use_backend_title_and_skip_detail_until_opened', () async {
final history = ControlledWatchHistory([
_progress('12', dramaTitle: '后端标题'),
]);
final catalog = ScriptedWatchCatalog();
final controller = WatchRecordsController(
history: history,
catalog: catalog,
);
await controller.load();
expect(controller.records.single.displayTitle, '后端标题');
expect(catalog.calls, isEmpty);
final detail = await controller.detailForOpen('12');
expect(detail?.id, '12');
expect(catalog.calls, ['12']);
});
test('should_reuse_cached_details_when_records_are_loaded_again', () async {
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
final catalog = ScriptedWatchCatalog();
@@ -93,6 +117,55 @@ void main() {
);
});
test('should_append_unique_records_and_stop_when_no_more_pages', () async {
final history = _PagedWatchHistory({
1: PageResult(total: 3, list: [_progress('1'), _progress('2')]),
2: PageResult(total: 3, list: [_progress('2'), _progress('3')]),
});
final catalog = ScriptedWatchCatalog();
final controller = WatchRecordsController(
history: history,
catalog: catalog,
pageSize: 2,
);
await controller.load();
await controller.loadMore();
await controller.loadMore();
expect(controller.records.map((record) => record.dramaId), ['1', '2', '3']);
expect(controller.total, 3);
expect(controller.hasMore, isFalse);
expect(history.pageRequests, [1, 2]);
expect(catalog.calls, ['1', '2', '3']);
});
test('should_retry_next_page_when_watch_load_more_fails', () async {
final history = _PagedWatchHistory({
1: PageResult(total: 2, list: [_progress('1')]),
2: PageResult(total: 2, list: [_progress('2')]),
})..failPages.add(2);
final controller = WatchRecordsController(
history: history,
catalog: ScriptedWatchCatalog(),
pageSize: 1,
);
await controller.load();
await controller.loadMore();
expect(controller.records.map((record) => record.dramaId), ['1']);
expect(controller.loadMoreError, 'progressError');
expect(controller.hasMore, isTrue);
history.failPages.clear();
await controller.loadMore();
expect(controller.records.map((record) => record.dramaId), ['1', '2']);
expect(controller.loadMoreError, isNull);
expect(history.pageRequests, [1, 2, 2]);
});
test('should_keep_existing_records_when_refresh_fails', () async {
final history = ControlledWatchHistory([_progress('1')]);
final controller = WatchRecordsController(
@@ -213,6 +286,7 @@ class ControlledWatchHistory implements WatchHistoryRepository {
final List<WatchProgress> items;
bool failRecent = false;
Object? recentError;
final List<int> recentPages = [];
bool failRemove = false;
bool failClear = false;
Completer<void>? removeGate;
@@ -230,10 +304,21 @@ class ControlledWatchHistory implements WatchHistoryRepository {
}) async => null;
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
recentPages.add(pageIndex);
if (recentError != null) throw recentError!;
if (failRecent) throw StateError('recent unavailable');
return items.take(limit).toList();
final offset = (pageIndex - 1) * pageSize;
if (offset >= items.length) {
return PageResult(total: items.length);
}
return PageResult(
total: items.length,
list: items.skip(offset).take(pageSize).toList(),
);
}
@override
@@ -285,3 +370,40 @@ class ScriptedWatchCatalog implements CatalogRepository {
return _detail(dramaId);
}
}
class _PagedWatchHistory implements WatchHistoryRepository {
_PagedWatchHistory(this.pages);
final Map<int, PageResult<WatchProgress>> pages;
final List<int> pageRequests = [];
final Set<int> failPages = {};
@override
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
pageRequests.add(pageIndex);
if (failPages.contains(pageIndex)) {
throw StateError('page unavailable');
}
return pages[pageIndex] ?? const PageResult();
}
@override
Future<WatchProgress?> progressFor(String dramaId) async => null;
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async => null;
@override
Future<void> remove(String dramaId) async {}
@override
Future<void> clear() async {}
}
+97 -4
View File
@@ -6,6 +6,7 @@ import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/page_result.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
@@ -98,6 +99,53 @@ void main() {
expect(list.physics, AppPullToRefresh.physics);
});
testWidgets('should_render_backend_title_episode_and_cover_metadata', (
tester,
) async {
final auth = await _auth(signedIn: true);
final history = CountingWatchHistory([
_progress(
'metadata',
dramaTitle: 'Backend Title',
dramaCover: '/files/backend.png',
episodeNumber: 3,
),
]);
final catalog = _AlwaysFailingWatchCatalog();
await _pumpPage(tester, auth: auth, history: history, catalog: catalog);
expect(find.text('Backend Title'), findsOneWidget);
expect(find.text('Episode 3'), findsOneWidget);
expect(catalog.calls, isEmpty);
expect(
find.byKey(const Key('watch-record-cover-metadata')),
findsOneWidget,
);
});
testWidgets('should_fallback_to_episode_title_and_cover_placeholder', (
tester,
) async {
final auth = await _auth(signedIn: true);
final history = CountingWatchHistory([
_progress('fallback', dramaTitle: '', episodeNumber: 4),
]);
await _pumpPage(
tester,
auth: auth,
history: history,
catalog: _AlwaysFailingWatchCatalog(),
);
expect(find.text('Episode 4'), findsOneWidget);
expect(
find.byKey(const Key('watch-record-cover-placeholder-fallback')),
findsOneWidget,
);
});
testWidgets(
'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails',
(tester) async {
@@ -238,9 +286,17 @@ void main() {
});
}
WatchProgress _progress(String dramaId) => WatchProgress(
WatchProgress _progress(
String dramaId, {
String? dramaTitle,
String? dramaCover,
int? episodeNumber,
}) => WatchProgress(
dramaId: dramaId,
episodeId: '$dramaId-1',
dramaTitle: dramaTitle,
dramaCover: dramaCover,
episodeNumber: episodeNumber,
position: const Duration(seconds: 12),
duration: const Duration(seconds: 60),
updatedAt: DateTime(2026, 10, 17, 12),
@@ -300,9 +356,19 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository {
int clearCalls = 0;
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
recentCalls++;
return visibleRecords.take(limit).toList();
final offset = (pageIndex - 1) * pageSize;
if (offset >= visibleRecords.length) {
return PageResult(total: visibleRecords.length);
}
return PageResult(
total: visibleRecords.length,
list: visibleRecords.skip(offset).take(pageSize).toList(),
);
}
@override
@@ -314,7 +380,10 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository {
class _FailingRecentHistory extends InMemoryWatchHistoryRepository {
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
throw const ApiException(100000, '账号不存在,请先注册');
}
}
@@ -347,3 +416,27 @@ class _FailingWatchCatalog implements CatalogRepository {
return _delegate.fetchDrama(dramaId);
}
}
class _AlwaysFailingWatchCatalog implements CatalogRepository {
final List<String> calls = [];
@override
Future<Drama> fetchDrama(String dramaId) async {
calls.add(dramaId);
throw StateError('missing detail');
}
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async => const FeedPage();
@override
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
}
+2 -1
View File
@@ -185,7 +185,8 @@ void main() {
await tester.pumpAndSettle();
await _openTheaterPlayer(tester);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
await tester.drag(find.byType(PageView), const Offset(0, -500));
await tester.pumpAndSettle();