Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48433f01d | ||
|
|
f2ae0c70b6 | ||
|
|
57f3029018 | ||
|
|
9093700a04 | ||
|
|
78114fa46d | ||
|
|
abfca7a3be | ||
|
|
235b50598a | ||
|
|
63d3b0f7ca | ||
|
|
7a588898fa | ||
|
|
dc4b60270d | ||
|
|
12b733d111 | ||
|
|
89a94f85c2 | ||
|
|
51c56d884b | ||
|
|
d06dce9753 | ||
|
|
7ad4681049 | ||
|
|
f302b396b7 | ||
|
|
5268adc999 | ||
|
|
47ca21406a | ||
|
|
759a02375a | ||
|
|
fe49c6d8b2 | ||
|
|
a25c500371 | ||
|
|
5bf10221a8 | ||
|
|
623c184243 | ||
|
|
7b068e8eb4 | ||
|
|
d076c57283 | ||
|
|
11e37a92b5 | ||
|
|
b0002abf82 | ||
|
|
9b78e43702 | ||
|
|
f3ec12e3c3 | ||
|
|
45a53180d9 | ||
|
|
d227ac69fb | ||
|
|
862b7e92dd | ||
|
|
8b53adc2be | ||
|
|
ecdff54f2e | ||
|
|
6c2f77926b | ||
|
|
86588d7faa | ||
|
|
91a622733c | ||
|
|
08980ff09b | ||
|
|
30a6bcccfc | ||
|
|
a42d3a26ac | ||
|
|
c278503d0e | ||
|
|
76f347cc35 | ||
|
|
349ea12404 | ||
|
|
dfc33027d8 | ||
|
|
13a14f3e85 | ||
|
|
f8769f2e1d | ||
|
|
a2a491d056 | ||
|
|
c9adf8b399 | ||
|
|
838ca2ce2a | ||
|
|
677e49fa6e | ||
|
|
dfa79bed88 | ||
|
|
3c4d65a9b2 | ||
|
|
c8392b2f2a | ||
|
|
ae61d9ec4c | ||
|
|
4d0be47ee3 | ||
|
|
83822a0cbb | ||
|
|
e6f34977f5 | ||
|
|
35860e056b | ||
|
|
01bef7f758 | ||
|
|
45e8707f1e | ||
|
|
d39d8d43c1 | ||
|
|
3fbfa9ab81 | ||
|
|
036bd29855 | ||
|
|
d2f2459a96 | ||
|
|
e90217bdfd | ||
|
|
ea1a9f424b | ||
|
|
18a1d06821 | ||
|
|
a66cdb2e1b | ||
|
|
6c72f8bf22 | ||
|
|
c892d9b995 | ||
|
|
2ae4aedac0 | ||
|
|
db6fbd58ea | ||
|
|
8dc9d55106 | ||
|
|
4908dc9d76 | ||
|
|
5b4526cb97 | ||
|
|
bac42bc653 | ||
|
|
bc971c17d5 | ||
|
|
cb58f649c1 | ||
|
|
f9f77090d9 | ||
|
|
5d459dd08a | ||
|
|
c3e6e02e47 | ||
|
|
e86daf559a | ||
|
|
9c0c1b8982 | ||
|
|
e0024ed123 | ||
|
|
c8ac2c9823 | ||
|
|
282f51c24b | ||
|
|
aa14d0a835 | ||
|
|
50edbf40c4 | ||
|
|
fc877f25bf | ||
|
|
f87b0d2689 | ||
|
|
146cd2a29e | ||
|
|
425ea4c0b7 | ||
|
|
a142a6c318 | ||
|
|
d081cff3e4 | ||
|
|
9876772d06 | ||
|
|
bc4c84a4ae |
No files matched your search
@@ -135,6 +135,27 @@ flutter run -d <device> \
|
||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
## 生产打包(Android)
|
||||
|
||||
生产包只包含以下两个 ABI,**打成一个 APK**,不做 split:
|
||||
|
||||
| ABI | 适用设备 |
|
||||
| --- | --- |
|
||||
| `arm64-v8a` | 现代手机(64 位) |
|
||||
| `armeabi-v7a` | 老 32 位机 |
|
||||
|
||||
```bash
|
||||
flutter build apk --release --target-platform android-arm64,android-arm
|
||||
# 产出单个:build/app/outputs/flutter-apk/app-release.apk(约 37MB)
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。
|
||||
- `x86_64` 只用于模拟器,不进入生产包。
|
||||
- 不要直接运行 `flutter build apk --release`:默认会编译三套原生库,包体约 57MB。
|
||||
- 只有需要进一步压缩单机下载体积时,才考虑 `--split-per-abi` 出分架构包(arm64 约 21MB、armv7 约 19MB),默认不用。
|
||||
|
||||
## 门禁
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,6 +29,10 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// 生产包只保留 arm64-v8a 与 armeabi-v7a,避免插件带入 x86_64 原生库
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
@@ -42,6 +46,13 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// 生产包不允许出现 x86_64:部分插件 AAR 会带入少量 x86_64 原生库,这里显式剔除。
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
variant.packaging.jniLibs.excludes.add("lib/x86_64/**")
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -5,22 +5,41 @@ 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
|
||||
import androidx.core.view.ViewCompat
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/pip")
|
||||
@@ -79,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)
|
||||
@@ -99,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)
|
||||
@@ -117,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 @@
|
||||
# 屏幕常亮与前台自动续播
|
||||
|
||||
## 目标
|
||||
|
||||
- Android 与 iOS 前台保持屏幕常亮,不引入插件或依赖。
|
||||
- 用户主动暂停后切后台再回前台,在播放器与票据状态安全时自动续播。
|
||||
- 锁定、blocked、票据失败、错误态及浮层遮挡场景不得强制起播。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. Android `MainActivity.onCreate` 添加 `FLAG_KEEP_SCREEN_ON`,保留现有渠道逻辑。
|
||||
2. iOS `AppDelegate` 设置 `UIApplication.shared.isIdleTimerDisabled = true`。
|
||||
3. 为 `PlaybackSession` 增加无浮层、路线可见且 active 播放器可用的恢复判断。
|
||||
4. 调整 `PlayerLogic.onLifecycle`,在现有 PiP 复位和过期票据刷新流程后安全设置播放意图。
|
||||
5. 补用户暂停恢复、锁定/票据失败不恢复、后台保存并暂停测试。
|
||||
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 真机常亮和生命周期行为由 PM 验收,本次不启停服务、不操作设备。
|
||||
- PiP 继续沿用现有可见性策略,不额外修改后台播放行为。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- Android 主 Activity 已添加 `FLAG_KEEP_SCREEN_ON`,未改动 Flutter 渠道与 PiP 逻辑。
|
||||
- iOS AppDelegate 已设置 `UIApplication.shared.isIdleTimerDisabled = true`。
|
||||
- 用户主动暂停后回前台可安全恢复;过期票据会等待重取完成后再恢复。
|
||||
- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景均不会强制续播。
|
||||
- 后台仍先保存进度并暂停。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 580 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -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,28 @@
|
||||
# 登录异常居中弹窗
|
||||
|
||||
## 目标
|
||||
|
||||
- 邮箱登录、邮箱注册及手机号 OTP 登录的提交异常改为屏幕居中弹窗。
|
||||
- 本地表单校验继续使用现有内联字段错误,不触发弹窗。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 新增 `lib/core/widgets/centered_error_dialog.dart`,统一弹窗样式、遮罩关闭和确认按钮。
|
||||
2. 补充登录失败、注册失败、确认按钮的中英文印尼语文案并重新生成 l10n。
|
||||
3. 调整邮箱认证页:接口异常展示居中弹窗,提交 busy 在展示弹窗前复位。
|
||||
4. 调整手机号弹窗:OTP 发送和验证码登录/注册异常展示居中弹窗,成功 SnackBar 保持不变。
|
||||
5. 补接口异常、注册标题、本地校验不弹窗和手机号两条异常路径测试。
|
||||
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 同级 FruitServer 的 `INBOX.md` 当前顶部为已处理 K100,未找到本需求条目;以用户消息中的完整需求为准。
|
||||
- 现有主工作区处于 `feature/payment-iap` 且有未提交 `pubspec.lock`,本次使用独立 worktree,不触碰该文件。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已新增居中错误弹窗组件及三语言文案,邮箱登录/注册和手机号 OTP/验证码提交异常均改为弹窗。
|
||||
- 本地字段校验仍走原内联错误;异常展示前提交 busy 已复位。
|
||||
- 接口异常、注册标题、未知异常网络文案、遮罩关闭、确认关闭、本地校验不弹窗均有测试覆盖。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 570 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 登录异常居中吐司
|
||||
|
||||
## 目标
|
||||
|
||||
- 将上一版认证异常 AlertDialog 替换为无交互的屏幕居中吐司。
|
||||
- 吐司淡入展示约 2 秒后自动淡出,不遮罩、不拦截手势。
|
||||
- 同一时刻只存在一个吐司,新消息替换旧消息。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 删除 `centered_error_dialog.dart`,新增 `centered_toast.dart`。
|
||||
2. 认证页和手机号弹窗异常路径改用 `showCenteredToast`,保留 busy 复位和成功 SnackBar。
|
||||
3. 清理不再使用的认证错误标题与确认按钮 l10n 键,重新生成 AppLocalizations。
|
||||
4. 更新认证测试为吐司断言,并补单例替换、自动消失测试。
|
||||
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 吐司使用 root Overlay,认证路由退出时仍会按自身计时器自动清理。
|
||||
- 测试必须 pump 超过展示和淡出时长,确保不遗留 Timer 或 OverlayEntry。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已删除 AlertDialog 组件,新增 OverlayEntry 居中吐司。
|
||||
- 邮箱和手机号异常改用吐司;busy 复位、成功 SnackBar 和本地内联校验保持不变。
|
||||
- 已清理三个不再使用的 l10n 键并重新生成 AppLocalizations。
|
||||
- 覆盖单例替换、点击穿透、自动消失、接口异常和本地校验不弹吐司。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 573 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -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,30 @@
|
||||
# 播放器 loading 与锁定进度条调整
|
||||
|
||||
## 目标
|
||||
|
||||
- 播放器票据与 session loading 圈统一为 24×24、线宽 2。
|
||||
- 缓冲/加载进度高光改为白色。
|
||||
- 未解锁剧集显示静态 inactive 轨道,不进入 loading,不允许拖动 seek。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 更新 `LoadingProgressTrack` 默认高光颜色,并补颜色断言。
|
||||
2. 为 `SeekProgressBar`、`PlayerOverlayData`、`HomeFeedOverlay` 和 `_FeedProgressBar` 增加 locked 状态。
|
||||
3. `PlayerPage` 使用 `logic.session.isLocked || episode.locked` 传入两个浮层。
|
||||
4. 缩小 `player-ticket-loading` 与 `player-session-loading` 的尺寸和线宽。
|
||||
5. 补首页/全屏锁定态、缓冲高光和两个 loading 指示器尺寸测试。
|
||||
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 锁定态只影响进度条 UI 与手势,不改播放、解锁或接口逻辑。
|
||||
- 首页浮层锁定线沿用当前 `Colors.white24` inactive 色,避免改变非锁定轨道样式。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 两个播放器 loading 圈均为 24×24、线宽 2、主色不变。
|
||||
- loading 高光默认改为白色,普通轨道与首页 white24 轨道颜色未变。
|
||||
- 首页与全屏播放器均按剧集锁定态渲染静态空轨道并禁用 seek。
|
||||
- 已覆盖锁定轨道高度、无 loading、无拖动 seek、loading 指示器尺寸和高光颜色。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 576 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -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。
|
||||
@@ -0,0 +1,732 @@
|
||||
# 开发进度
|
||||
|
||||
## 2026-10-21 K96 剧场页服务端分类分页
|
||||
|
||||
- 已完整读取 K96 `docs/plans/INBOX.md`。
|
||||
- 已从最新 `origin/develop` 创建 ReelShort `feat/k96-theater-feed`。
|
||||
- 计划见 FruitServer `docs/plans/2026-10-21-K96-theater-feed.md`。
|
||||
- 客户端将新增 TheaterFeedController,并让剧场「全部 / 分类」改用
|
||||
`/app/feed` 服务端游标分页;不改首页 K94/K95 已交付逻辑。
|
||||
- 已完成 `CatalogApi.feed` / `CatalogRepository.fetchFeed` 可选 `categoryId`,
|
||||
非空才拼请求参数。
|
||||
- 已新增 `TheaterFeedController`:按分类独立缓存,缓存先渲染后静默刷新,
|
||||
10 秒超时、游标追加去重、失败保留旧数据、失败分类切回自动重试。
|
||||
- 剧场页「全部」和分类 chips 已改用 theater feed;滚动剩余不足 400px 触发
|
||||
`loadMore`,下拉刷新同时执行 categories 与 theater 刷新,搜索模式保持独立。
|
||||
- 已补分类参数、缓存切换、分页去重、hasMore 停止、失败保留、失败重试、
|
||||
分类点击请求和滚动加载 widget 测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:52 +553: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 24.8s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K97 修复 iOS 评论输入弹窗键盘避让
|
||||
|
||||
- 已完整读取 K97 `docs/plans/INBOX.md`。
|
||||
- 已从最新 `origin/develop` 创建 `fix/k97-ios-comment-keyboard`。
|
||||
- 计划见 FruitServer `docs/plans/2026-10-21-K97-ios-comment-keyboard.md`。
|
||||
- Android 保留既有 pinned/channel 策略;iOS 与其他平台改用实时
|
||||
`MediaQuery.viewInsets.bottom`,并补 iOS 键盘避让测试。
|
||||
- 已完成 `_isAndroid` 平台分流;Android 的 channel、80ms 防抖和 pinned 逻辑保持不变,
|
||||
iOS/其他平台直接读取实时 viewInsets,超过屏幕 60% 的异常值按 0 处理。
|
||||
- 面板已使用 160ms `easeOutCubic` AnimatedPadding;Android 不包裹 SafeArea,
|
||||
iOS/其他平台使用 `SafeArea(top: false)` 处理 home indicator。
|
||||
- 已补 iOS insets=300/0 测试,发表按钮在键盘上方;Android pinned 既有用例全部通过。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:54 +555: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 26.2s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
|
||||
$ flutter build ios --simulator --no-codesign
|
||||
Xcode build done. 44.0s
|
||||
✓ Built build/ios/iphonesimulator/Runner.app
|
||||
```
|
||||
|
||||
解读:静态分析零告警,555 项全量测试通过,Android 与 iOS simulator 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K97.1 剧场搜索框圆角改小
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k97.1-theater-search-radius`。
|
||||
- 仅将剧场搜索框圆角从 22 调整为 12,高度 44、颜色、聚焦态及
|
||||
清空/识剧逻辑保持不变,并同步更新对应 widget 断言。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:55 +555: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 25.8s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,555 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98 修复登录与手机号登录键盘遮挡
|
||||
|
||||
- 已完整读取 K98 `docs/plans/INBOX.md`。
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98-auth-keyboard`。
|
||||
- 计划见 FruitServer `docs/plans/2026-10-21-K98-auth-keyboard.md`。
|
||||
- 将统一 viewInsets/channel 有效键盘高度,并给登录页与手机号弹窗增加
|
||||
焦点/键盘变化后的字段滚动校正。
|
||||
- 已新增 `effectiveKeyboardInset`,兼容 iOS 实时 viewInsets 与 Android
|
||||
`adjustNothing` 下的 MethodChannel 键盘高度。
|
||||
- 登录/注册页已增加 ScrollController、notifier 监听和邮箱/密码焦点校正;
|
||||
手机号弹窗已同样接入手机号/验证码框滚动校正。
|
||||
- 已补 iOS 登录/注册、Android channel、手机号验证码框键盘避让测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:52 +559: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 26.2s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,559 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98.1 收藏与观看记录直接进入播放页
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98.1-play-from-lists`。
|
||||
- 收藏卡片已改为调用 `playDramaFromCatalog`,不再进入详情页。
|
||||
- 观看记录卡片已按 `progress.episodeId` 直达播放页对应集;episodeId 为空时从第 1 集起播。
|
||||
- 详情未加载完成的观看记录仍保持原禁用、加载和重试行为。
|
||||
- 已补收藏及观看记录直达 PlayerPage 的路由断言。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
01:09 +561: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 28.7s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,561 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98.2 修复 feed 更新触发 build 期通知
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98.2-feed-build-notify`。
|
||||
- PlayerPage 的 feed 更新改为帧后调度,同一帧多次变化继续由 PlayerLogic 合并。
|
||||
- PlayerLogic 在非 idle 调度阶段延后 feed 通知与 PageController 跳页,并给延迟通知去重。
|
||||
- HomeLogic.onDramaChanged 在 build/layout/paint 阶段改为帧后通知。
|
||||
- 已补 build 阶段 updateFeed、最终 PageController 位置及 HomeLogic 延后通知回归测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:55 +563: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 26.4s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,563 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98.3 修复频道切换复用已释放视频控制器
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98.3-disposed-video-controller`。
|
||||
- `PlaybackSession.active` 与 `preparedPlayerAt` 不再暴露 closed/retiring slot;
|
||||
`_retire` 先清空活动引用并通知,再等待界面帧边界后暂停和释放播放器。
|
||||
- `PlayerLogic._replaceSession` 切换 session 后等待 `endOfFrame`,并再次校验
|
||||
revision 后才关闭旧 session;其它 session 回收路径同样使用该帧边界。
|
||||
- 已补 retiring player 脱钩单测,以及真实 `PlayerVideoSurface` 经 feed 切换、
|
||||
连续 pump 后不出现 disposed controller 异常的回归测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:58 +565: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 35.7s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-22 登录异常改为屏幕居中弹窗
|
||||
|
||||
- 已从 `origin/develop`(`9b78e43`)创建 `feature/login-error-center-dialog`。
|
||||
- 新增 `lib/core/widgets/centered_error_dialog.dart`,统一居中样式、遮罩关闭、确认按钮。
|
||||
- 邮箱登录/注册与手机号 OTP/验证码异常改为居中弹窗;字段校验保持内联,成功提示不变。
|
||||
- 新增 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 三语言文案并重新生成 l10n。
|
||||
- 异常弹窗前提交 busy 已复位,避免 loading 卡在弹窗打开期间。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter gen-l10n
|
||||
Because l10n.yaml exists, the options defined there will be used instead.
|
||||
To use the command line arguments, delete the l10n.yaml file in the Flutter project.
|
||||
|
||||
$ flutter test --no-pub test/auth/auth_page_test.dart test/auth/phone_auth_sheet_test.dart
|
||||
00:02 +20: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:49 +570: All tests passed!
|
||||
```
|
||||
|
||||
解读:登录异常、注册标题、未知异常回退、本地校验不弹窗、遮罩/按钮关闭和手机号两条
|
||||
异常路径均通过;全量静态分析零告警,570 项测试全过。`pubspec.lock` 已还原,
|
||||
未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 登录异常调整为中心吐司
|
||||
|
||||
- 已从 `develop`(`11e37a9`)创建 `feature/login-error-center-toast`。
|
||||
- 删除 AlertDialog 组件,新增 OverlayEntry 居中吐司:淡入淡出、约 2 秒消失、不拦截手势。
|
||||
- 吐司保持全局单例,新消息替换旧消息;邮箱与手机号异常路径均已切换。
|
||||
- 本地表单校验继续内联,手机号验证码发送成功 SnackBar 保持不变。
|
||||
- 清理 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 并重新生成 l10n。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter gen-l10n
|
||||
Because l10n.yaml exists, the options defined there will be used instead.
|
||||
To use the command line arguments, delete the l10n.yaml file in the Flutter project.
|
||||
|
||||
$ flutter test --no-pub test/core/centered_toast_test.dart
|
||||
00:00 +3: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/auth/auth_page_test.dart test/auth/phone_auth_sheet_test.dart
|
||||
00:02 +20: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
01:00 +573: All tests passed!
|
||||
```
|
||||
|
||||
解读:吐司单例替换、点击穿透和自动消失通过;认证异常与本地校验路径通过;
|
||||
全量静态分析零告警,573 项测试全过。`pubspec.lock` 已还原,未启停服务,
|
||||
未操作真机。
|
||||
|
||||
## 2026-10-22 播放器 loading 与锁定进度条调整
|
||||
|
||||
- 已从 `develop`(`7b068e8`)创建 `feature/player-loading-polish`。
|
||||
- 播放器票据与 session loading 圈统一为 24×24、线宽 2。
|
||||
- loading 高光默认改为白色,普通轨道色与首页 white24 轨道保持不变。
|
||||
- 首页和全屏播放器进度条增加 locked 状态;锁定态显示静态 inactive 线,不进入 loading,
|
||||
禁用拖动与 seek。
|
||||
- 已补 loading 指示器尺寸、缓冲高光颜色、首页/全屏锁定态和锁定轨道高度测试。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/player/loading_progress_track_test.dart test/playback/seek_progress_bar_test.dart
|
||||
00:00 +8: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/home/home_preview_layout_test.dart
|
||||
00:01 +7: 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:50 +576: All tests passed!
|
||||
```
|
||||
|
||||
解读:加载高光、锁定静态轨道、拖动禁用和两个 loading 指示器尺寸均通过;
|
||||
全量静态分析零告警,576 项测试全过。`pubspec.lock` 已还原,未启停服务,
|
||||
未操作真机。
|
||||
|
||||
## 2026-10-22 屏幕常亮与前台自动续播
|
||||
|
||||
- 已从 `develop`(`5bf1022`)创建 `feature/keep-awake-auto-resume`。
|
||||
- Android 主 Activity 添加 `FLAG_KEEP_SCREEN_ON`,保留已有渠道、PiP 和键盘逻辑。
|
||||
- iOS AppDelegate 开启 `isIdleTimerDisabled`,前台保持常亮。
|
||||
- 用户主动暂停后回前台安全恢复;过期票据先完成重取再设置播放意图。
|
||||
- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景不会强制续播。
|
||||
- 后台仍立即保存进度并暂停,不做后台播放。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/player/player_logic_test.dart
|
||||
00:00 +35: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:50 +580: All tests passed!
|
||||
```
|
||||
|
||||
解读:用户暂停恢复、过期票据重取、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` 既有改动未提交。
|
||||
@@ -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,10 +35,16 @@ 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]?
|
||||
) -> Bool {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
@@ -43,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -15,6 +15,9 @@ class Episode {
|
||||
this.videoUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.locked = false,
|
||||
this.likeCount,
|
||||
this.commentCount,
|
||||
this.thumbnailsUrl,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -28,8 +31,16 @@ class Episode {
|
||||
|
||||
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
|
||||
final bool locked;
|
||||
final int? likeCount;
|
||||
final int? commentCount;
|
||||
final String? thumbnailsUrl;
|
||||
|
||||
Episode withVideoUrl(String url) => Episode(
|
||||
Episode withVideoUrl(
|
||||
String url, {
|
||||
int? likeCount,
|
||||
int? commentCount,
|
||||
String? thumbnailsUrl,
|
||||
}) => Episode(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
@@ -37,6 +48,9 @@ class Episode {
|
||||
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) {
|
||||
@@ -48,6 +62,9 @@ class Episode {
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
locked: json['locked'] == true,
|
||||
videoUrl: json['playUrl'] as String? ?? '',
|
||||
likeCount: _optionalInt(json['likeCount']),
|
||||
commentCount: _optionalInt(json['commentCount']),
|
||||
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -150,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;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'drama.dart';
|
||||
|
||||
/// `/app/feed` 的游标分页数据。
|
||||
class FeedPage {
|
||||
const FeedPage({
|
||||
this.dramas = const [],
|
||||
this.nextCursor,
|
||||
this.hasMore = false,
|
||||
});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final String? nextCursor;
|
||||
final bool hasMore;
|
||||
|
||||
factory FeedPage.fromJson(Map<String, dynamic> json) {
|
||||
final rawCursor = json['nextCursor'];
|
||||
final cursor = rawCursor?.toString();
|
||||
return FeedPage(
|
||||
dramas: (json['list'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(),
|
||||
nextCursor: cursor == null || cursor.isEmpty || cursor == 'null'
|
||||
? null
|
||||
: cursor,
|
||||
hasMore: json['hasMore'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 互动目标类型,契约只允许短剧与评论。
|
||||
/// 互动目标类型。
|
||||
enum InteractionTargetType {
|
||||
drama('DRAMA'),
|
||||
episode('EPISODE'),
|
||||
comment('COMMENT');
|
||||
|
||||
const InteractionTargetType(this.apiValue);
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -14,6 +14,9 @@ class PlayTicket {
|
||||
this.unlockCoin = 0,
|
||||
this.previewSeconds = 0,
|
||||
this.qualities = const [],
|
||||
this.thumbnailsUrl,
|
||||
this.likeCount,
|
||||
this.commentCount,
|
||||
});
|
||||
|
||||
static const String free = 'FREE';
|
||||
@@ -38,6 +41,9 @@ class PlayTicket {
|
||||
|
||||
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
|
||||
final List<String> qualities;
|
||||
final String? thumbnailsUrl;
|
||||
final int? likeCount;
|
||||
final int? commentCount;
|
||||
|
||||
bool get needsUnlock =>
|
||||
accessType == coinRequired || accessType == vipRequired;
|
||||
@@ -58,6 +64,18 @@ class PlayTicket {
|
||||
.whereType<String>()
|
||||
.where((quality) => quality.isNotEmpty)
|
||||
.toList(),
|
||||
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
|
||||
likeCount: json['likeCount'] == null
|
||||
? null
|
||||
: jsonToInt(json['likeCount']),
|
||||
commentCount: json['commentCount'] == null
|
||||
? null
|
||||
: jsonToInt(json['commentCount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _optionalString(dynamic value) {
|
||||
final text = value is String ? value.trim() : '';
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/feed_page.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/search_result.dart';
|
||||
import 'api_client.dart';
|
||||
@@ -17,6 +18,29 @@ class CatalogApi {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||
}
|
||||
|
||||
/// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。
|
||||
Future<FeedPage> feed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async {
|
||||
return FeedPage.fromJson(
|
||||
_asMap(
|
||||
await _client.get(
|
||||
'/app/feed',
|
||||
query: {
|
||||
if (types.isNotEmpty) 'types': types.join(','),
|
||||
if (categoryId != null && categoryId.isNotEmpty)
|
||||
'categoryId': categoryId,
|
||||
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
||||
'size': '$size',
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
|
||||
Future<SearchResult> search(
|
||||
String keyword, {
|
||||
|
||||
@@ -13,6 +13,7 @@ class CommentApi {
|
||||
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
@@ -21,6 +22,7 @@ class CommentApi {
|
||||
'/app/comments',
|
||||
query: {
|
||||
'dramaId': dramaId,
|
||||
'episodeId': ?episodeId,
|
||||
'rootId': ?rootId,
|
||||
'cursor': ?cursor,
|
||||
'size': '$size',
|
||||
@@ -31,10 +33,10 @@ class CommentApi {
|
||||
}
|
||||
|
||||
/// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。
|
||||
Future<int> count(String dramaId) async {
|
||||
Future<int> count(String dramaId, {String? episodeId}) async {
|
||||
final data = await _client.get(
|
||||
'/app/comments/count',
|
||||
query: {'dramaId': dramaId},
|
||||
query: {'dramaId': dramaId, 'episodeId': ?episodeId},
|
||||
);
|
||||
return jsonToInt(data);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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/feed_page.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
@@ -13,6 +14,13 @@ abstract interface class CatalogRepository {
|
||||
|
||||
Future<HomeFeed> fetchDiscoverFeed();
|
||||
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
});
|
||||
|
||||
Future<Drama> fetchDrama(String dramaId);
|
||||
}
|
||||
|
||||
@@ -28,6 +36,18 @@ class HttpCatalogRepository implements CatalogRepository {
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) => _api.feed(
|
||||
types: types,
|
||||
categoryId: categoryId,
|
||||
cursor: cursor,
|
||||
size: size,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
}
|
||||
@@ -8,13 +8,14 @@ abstract interface class CommentRepository {
|
||||
/// 拉取评论或回复的分页。
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
});
|
||||
|
||||
/// 查询单部剧的有效评论总数。
|
||||
Future<int> count(String dramaId);
|
||||
Future<int> count(String dramaId, {String? episodeId});
|
||||
|
||||
/// 发表评论或回复。
|
||||
Future<DramaComment> publish({
|
||||
@@ -37,18 +38,21 @@ class HttpCommentRepository implements CommentRepository {
|
||||
@override
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
}) => _api.page(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
rootId: rootId,
|
||||
cursor: cursor,
|
||||
size: size,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<int> count(String dramaId) => _api.count(dramaId);
|
||||
Future<int> count(String dramaId, {String? episodeId}) =>
|
||||
_api.count(dramaId, episodeId: episodeId);
|
||||
|
||||
@override
|
||||
Future<DramaComment> publish({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,7 @@ class CommentController extends ChangeNotifier {
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
size: commentPageSize,
|
||||
);
|
||||
_comments
|
||||
@@ -101,6 +102,7 @@ class CommentController extends ChangeNotifier {
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
cursor: cursor,
|
||||
size: commentPageSize,
|
||||
);
|
||||
@@ -128,6 +130,7 @@ class CommentController extends ChangeNotifier {
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
rootId: rootId,
|
||||
size: commentPageSize,
|
||||
);
|
||||
@@ -157,6 +160,7 @@ class CommentController extends ChangeNotifier {
|
||||
try {
|
||||
final page = await _commentRepository.page(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
rootId: rootId,
|
||||
cursor: cursor,
|
||||
size: commentPageSize,
|
||||
|
||||
@@ -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,222 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/repositories/catalog_repository.dart';
|
||||
import '../../../pages/home/home_feed_channel.dart';
|
||||
|
||||
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
|
||||
class HomeFeedController extends ChangeNotifier {
|
||||
HomeFeedController(
|
||||
this._repository, {
|
||||
this.requestTimeout = defaultRequestTimeout,
|
||||
}) : _states = {
|
||||
for (final channel in HomeFeedChannel.values)
|
||||
channel: _ChannelFeedState(),
|
||||
};
|
||||
|
||||
static const int pageSize = 10;
|
||||
static const Duration defaultRequestTimeout = Duration(seconds: 10);
|
||||
|
||||
final CatalogRepository _repository;
|
||||
final Duration requestTimeout;
|
||||
final Map<HomeFeedChannel, _ChannelFeedState> _states;
|
||||
HomeFeedChannel _channel = HomeFeedChannel.recommend;
|
||||
bool _disposed = false;
|
||||
|
||||
HomeFeedChannel get channel => _channel;
|
||||
String? get lastError => _state(_channel).lastError;
|
||||
List<Drama> get dramas => _state(_channel).dramas;
|
||||
String? get cursor => _state(_channel).cursor;
|
||||
bool get hasMore => _state(_channel).hasMore;
|
||||
bool get loaded => _state(_channel).loaded;
|
||||
bool get loadingMore => _state(_channel).loadingMore;
|
||||
bool get loadingFirstPage => !loaded && _state(_channel).loading;
|
||||
|
||||
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
|
||||
void seedRecommend(List<Drama> dramas) {
|
||||
final state = _state(HomeFeedChannel.recommend);
|
||||
state
|
||||
..requestSeq = state.requestSeq + 1
|
||||
..dramas = List.unmodifiable(dramas)
|
||||
..cursor = null
|
||||
..hasMore = false
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..loadingMore = false
|
||||
..lastError = null
|
||||
..inFlight = null;
|
||||
_notifyIfCurrent(HomeFeedChannel.recommend);
|
||||
}
|
||||
|
||||
/// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。
|
||||
Future<void> prefetchChannels() async {
|
||||
await Future.wait([
|
||||
_prefetchChannel(HomeFeedChannel.anime),
|
||||
_prefetchChannel(HomeFeedChannel.live),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。
|
||||
Future<void> selectChannel(HomeFeedChannel next) async {
|
||||
if (next == _channel) return;
|
||||
_channel = next;
|
||||
_notify();
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
if (state.skipNextRefresh) {
|
||||
state.skipNextRefresh = false;
|
||||
} else if (!state.loading) {
|
||||
unawaited(_refresh(next, keepData: true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.loading) {
|
||||
final inFlight = state.inFlight;
|
||||
if (inFlight != null) await inFlight;
|
||||
state.skipNextRefresh = false;
|
||||
return;
|
||||
}
|
||||
await _refresh(next, keepData: false);
|
||||
}
|
||||
|
||||
/// 刷新当前频道,失败时保留旧数据。
|
||||
Future<bool> refreshCurrent() async {
|
||||
final state = _state(_channel);
|
||||
if (state.loading) {
|
||||
return await state.inFlight ?? false;
|
||||
}
|
||||
return _refresh(_channel, keepData: true);
|
||||
}
|
||||
|
||||
/// 加载当前频道下一页并去重追加。
|
||||
Future<void> loadMore() async {
|
||||
final channel = _channel;
|
||||
final state = _state(channel);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final seq = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notifyIfCurrent(channel);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(
|
||||
types: channel.apiTypes,
|
||||
cursor: state.cursor,
|
||||
size: pageSize,
|
||||
)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(channel, seq)) return;
|
||||
final seen = state.dramas.map((drama) => drama.id).toSet();
|
||||
final appended = page.dramas
|
||||
.where((drama) => seen.add(drama.id))
|
||||
.toList();
|
||||
state
|
||||
..dramas = List.unmodifiable([...state.dramas, ...appended])
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..lastError = null;
|
||||
} catch (error) {
|
||||
if (_acceptSequence(channel, seq)) {
|
||||
state.lastError = error.toString();
|
||||
}
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
_notifyIfCurrent(channel);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refresh(HomeFeedChannel channel, {required bool keepData}) {
|
||||
final state = _state(channel);
|
||||
final operation = _performRefresh(channel, keepData: keepData);
|
||||
state.inFlight = operation;
|
||||
unawaited(_clearInFlight(state, operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<void> _prefetchChannel(HomeFeedChannel channel) async {
|
||||
final state = _state(channel);
|
||||
if (state.loaded || state.loading) return;
|
||||
final success = await _refresh(channel, keepData: false);
|
||||
if (success && !_disposed) state.skipNextRefresh = true;
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh(
|
||||
HomeFeedChannel channel, {
|
||||
required bool keepData,
|
||||
}) async {
|
||||
final state = _state(channel);
|
||||
final seq = ++state.requestSeq;
|
||||
state
|
||||
..loading = true
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(channel);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(types: channel.apiTypes, size: pageSize)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(channel, seq)) return false;
|
||||
state
|
||||
..dramas = List.unmodifiable(page.dramas)
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(channel);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_acceptSequence(channel, seq)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = error.toString();
|
||||
_notifyIfCurrent(channel);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearInFlight(
|
||||
_ChannelFeedState state,
|
||||
Future<bool> operation,
|
||||
) async {
|
||||
await operation;
|
||||
if (identical(state.inFlight, operation)) state.inFlight = null;
|
||||
}
|
||||
|
||||
_ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!;
|
||||
|
||||
bool _acceptSequence(HomeFeedChannel channel, int seq) =>
|
||||
!_disposed && _state(channel).requestSeq == seq;
|
||||
|
||||
void _notifyIfCurrent(HomeFeedChannel channel) {
|
||||
if (!_disposed && channel == _channel) _notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
String? lastError;
|
||||
int requestSeq = 0;
|
||||
Future<bool>? inFlight;
|
||||
bool skipNextRefresh = false;
|
||||
}
|
||||
@@ -17,6 +17,9 @@ class InteractionController extends ChangeNotifier {
|
||||
final Set<String> _likedDramaIds = {};
|
||||
final Set<String> _busyDramaIds = {};
|
||||
final Map<String, int> _likeCounts = {};
|
||||
final Set<String> _likedEpisodeIds = {};
|
||||
final Set<String> _busyEpisodeIds = {};
|
||||
final Map<String, int> _episodeLikeCounts = {};
|
||||
bool _disposed = false;
|
||||
|
||||
/// 当前会话内该剧是否已点赞。
|
||||
@@ -28,42 +31,75 @@ class InteractionController extends ChangeNotifier {
|
||||
/// 服务端最新点赞数,仅在点赞接口成功返回后可用。
|
||||
int? likeCount(String dramaId) => _likeCounts[dramaId];
|
||||
|
||||
/// 当前会话内该集是否已点赞。
|
||||
bool isEpisodeLiked(String episodeId) => _likedEpisodeIds.contains(episodeId);
|
||||
|
||||
/// 该集点赞请求是否进行中。
|
||||
bool isEpisodeBusy(String episodeId) => _busyEpisodeIds.contains(episodeId);
|
||||
|
||||
/// 该集服务端最新点赞数。
|
||||
int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId];
|
||||
|
||||
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
||||
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
|
||||
String dramaId,
|
||||
) async {
|
||||
if (_busyDramaIds.contains(dramaId)) {
|
||||
Future<OperationResult<LikeToggleResult>> toggleDramaLike(String dramaId) =>
|
||||
_toggleLike(
|
||||
id: dramaId,
|
||||
targetType: InteractionTargetType.drama,
|
||||
likedIds: _likedDramaIds,
|
||||
busyIds: _busyDramaIds,
|
||||
counts: _likeCounts,
|
||||
);
|
||||
|
||||
/// 乐观切换分集点赞,失败时回滚并以服务端返回状态校准。
|
||||
Future<OperationResult<LikeToggleResult>> toggleEpisodeLike(
|
||||
String episodeId,
|
||||
) => _toggleLike(
|
||||
id: episodeId,
|
||||
targetType: InteractionTargetType.episode,
|
||||
likedIds: _likedEpisodeIds,
|
||||
busyIds: _busyEpisodeIds,
|
||||
counts: _episodeLikeCounts,
|
||||
);
|
||||
|
||||
Future<OperationResult<LikeToggleResult>> _toggleLike({
|
||||
required String id,
|
||||
required InteractionTargetType targetType,
|
||||
required Set<String> likedIds,
|
||||
required Set<String> busyIds,
|
||||
required Map<String, int> counts,
|
||||
}) async {
|
||||
if (busyIds.contains(id)) {
|
||||
return const OperationResult.failure(null);
|
||||
}
|
||||
final wasLiked = _likedDramaIds.contains(dramaId);
|
||||
_busyDramaIds.add(dramaId);
|
||||
final wasLiked = likedIds.contains(id);
|
||||
busyIds.add(id);
|
||||
if (wasLiked) {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
likedIds.remove(id);
|
||||
} else {
|
||||
_likedDramaIds.add(dramaId);
|
||||
likedIds.add(id);
|
||||
}
|
||||
_notify();
|
||||
try {
|
||||
final result = await _repository.toggleLike(
|
||||
targetType: InteractionTargetType.drama,
|
||||
targetId: dramaId,
|
||||
targetType: targetType,
|
||||
targetId: id,
|
||||
);
|
||||
if (result.liked) {
|
||||
_likedDramaIds.add(dramaId);
|
||||
likedIds.add(id);
|
||||
} else {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
likedIds.remove(id);
|
||||
}
|
||||
_likeCounts[dramaId] = result.count;
|
||||
counts[id] = result.count;
|
||||
return OperationResult.success(result);
|
||||
} catch (failure) {
|
||||
if (wasLiked) {
|
||||
_likedDramaIds.add(dramaId);
|
||||
likedIds.add(id);
|
||||
} else {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
likedIds.remove(id);
|
||||
}
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
} finally {
|
||||
_busyDramaIds.remove(dramaId);
|
||||
busyIds.remove(id);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
@@ -93,12 +129,18 @@ class InteractionController extends ChangeNotifier {
|
||||
void clear() {
|
||||
if (_likedDramaIds.isEmpty &&
|
||||
_busyDramaIds.isEmpty &&
|
||||
_likeCounts.isEmpty) {
|
||||
_likeCounts.isEmpty &&
|
||||
_likedEpisodeIds.isEmpty &&
|
||||
_busyEpisodeIds.isEmpty &&
|
||||
_episodeLikeCounts.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_likedDramaIds.clear();
|
||||
_busyDramaIds.clear();
|
||||
_likeCounts.clear();
|
||||
_likedEpisodeIds.clear();
|
||||
_busyEpisodeIds.clear();
|
||||
_episodeLikeCounts.clear();
|
||||
_notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
this.prepare,
|
||||
this.activate,
|
||||
PlaybackCoordinator? coordinator,
|
||||
this.waitForFrame,
|
||||
}) : _coordinator = coordinator ?? PlaybackCoordinator();
|
||||
|
||||
final List<Episode> episodes;
|
||||
@@ -60,6 +61,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
|
||||
/// Runs only when a slot becomes current (e.g. progress listeners).
|
||||
final PlayerPreparation? activate;
|
||||
final Future<void> Function()? waitForFrame;
|
||||
final PlaybackCoordinator _coordinator;
|
||||
final Set<_PlayerSlot> _live = {};
|
||||
_PlayerSlot? _active;
|
||||
@@ -80,8 +82,17 @@ class PlaybackSession extends ChangeNotifier {
|
||||
|
||||
int get currentIndex => _currentIndex;
|
||||
Episode get currentEpisode => episodes[_currentIndex];
|
||||
PlayerPort? get active =>
|
||||
_active?.index == _currentIndex ? _active?.player : null;
|
||||
PlayerPort? get active {
|
||||
final slot = _active;
|
||||
if (_closed ||
|
||||
slot == null ||
|
||||
slot.retiring ||
|
||||
slot.index != _currentIndex) {
|
||||
return null;
|
||||
}
|
||||
return slot.player;
|
||||
}
|
||||
|
||||
bool get loading => _loading;
|
||||
bool get userWantsPlay => _userWantsPlay;
|
||||
bool get canAutoAdvance => _mayPlay && !_loading && active != null;
|
||||
@@ -93,18 +104,50 @@ class PlaybackSession extends ChangeNotifier {
|
||||
Future<void> get playbackSettled => _operations;
|
||||
Future<void> get preloadSettled => _preloadTask;
|
||||
|
||||
/// 当前页面可见、无浮层遮挡且存在可用播放器时可安全恢复。
|
||||
bool get canResumeAfterBackground =>
|
||||
!_closed && _routeVisible && !_overlayOpen && active != null;
|
||||
|
||||
bool get _visible => !_closed && _appResumed && _routeVisible;
|
||||
bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay;
|
||||
|
||||
/// Exposes only prepared slots so the adjacent page can mount its video texture
|
||||
/// during a swipe without starting audio or registering history listeners.
|
||||
PlayerPort? preparedPlayerAt(int index) {
|
||||
if (_closed) return null;
|
||||
for (final slot in [_active, _next]) {
|
||||
if (slot?.index == index && slot!.prepared) return slot.player;
|
||||
if (slot == null ||
|
||||
slot.retiring ||
|
||||
slot.index != index ||
|
||||
!slot.prepared) {
|
||||
continue;
|
||||
}
|
||||
return slot.player;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
|
||||
void remapCurrentIndex(int index, {int? preloadIndex}) {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
final activeSlot = _active;
|
||||
if (activeSlot == null || activeSlot.index != _currentIndex) {
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
_generation++;
|
||||
activeSlot.index = index;
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
final staleNext = _next;
|
||||
_next = null;
|
||||
_notify();
|
||||
unawaited(_enqueue(() async => _retire(staleNext)));
|
||||
if (_visible && !_overlayOpen) _schedulePreload();
|
||||
}
|
||||
|
||||
Future<void> _enqueue(Future<void> Function() operation) {
|
||||
final task = _operations.then((_) => operation());
|
||||
// A plugin failure must not poison subsequent transitions or cleanup.
|
||||
@@ -141,6 +184,11 @@ class PlaybackSession extends ChangeNotifier {
|
||||
Future<void> _retire(_PlayerSlot? slot) async {
|
||||
if (slot == null || !_live.contains(slot)) return;
|
||||
slot.retiring = true;
|
||||
final cleared = _active == slot || _next == slot;
|
||||
if (_active == slot) _active = null;
|
||||
if (_next == slot) _next = null;
|
||||
if (cleared) _notify();
|
||||
await waitForFrame?.call();
|
||||
await slot.ready;
|
||||
try {
|
||||
await slot.player.pause();
|
||||
@@ -363,7 +411,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
|
||||
class _PlayerSlot {
|
||||
_PlayerSlot(this.index, this.player);
|
||||
final int index;
|
||||
int index;
|
||||
final PlayerPort player;
|
||||
late final Future<void> ready;
|
||||
bool prepared = false;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/repositories/catalog_repository.dart';
|
||||
|
||||
/// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。
|
||||
class TheaterFeedController extends ChangeNotifier {
|
||||
TheaterFeedController(
|
||||
this._repository, {
|
||||
this.requestTimeout = defaultRequestTimeout,
|
||||
});
|
||||
|
||||
static const int pageSize = 20;
|
||||
static const Duration defaultRequestTimeout = Duration(seconds: 10);
|
||||
|
||||
final CatalogRepository _repository;
|
||||
final Duration requestTimeout;
|
||||
final Map<String?, _CategoryFeedState> _states = {};
|
||||
String? _categoryId;
|
||||
bool _initialized = false;
|
||||
bool _disposed = false;
|
||||
|
||||
String? get categoryId => _categoryId;
|
||||
String? get lastError => _state(_categoryId).lastError;
|
||||
List<Drama> get dramas => _state(_categoryId).dramas;
|
||||
String? get cursor => _state(_categoryId).cursor;
|
||||
bool get hasMore => _state(_categoryId).hasMore;
|
||||
bool get loaded => _state(_categoryId).loaded;
|
||||
bool get loadingMore => _state(_categoryId).loadingMore;
|
||||
bool get loadingFirstPage => !loaded && _state(_categoryId).loading;
|
||||
|
||||
/// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。
|
||||
Future<void> select(String? next) async {
|
||||
if (_initialized && next == _categoryId) return;
|
||||
_categoryId = next;
|
||||
_initialized = true;
|
||||
_notify();
|
||||
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
if (!state.loading) {
|
||||
unawaited(_refresh(next, keepData: true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.loading) {
|
||||
final inFlight = state.inFlight;
|
||||
if (inFlight != null) await inFlight;
|
||||
return;
|
||||
}
|
||||
await _refresh(next, keepData: false);
|
||||
}
|
||||
|
||||
/// 刷新当前分类;失败时保留已有列表。
|
||||
Future<bool> refreshCurrent() async {
|
||||
final state = _state(_categoryId);
|
||||
if (state.loading) {
|
||||
return await state.inFlight ?? false;
|
||||
}
|
||||
return _refresh(_categoryId, keepData: true);
|
||||
}
|
||||
|
||||
/// 加载当前分类下一页并去重追加。
|
||||
Future<void> loadMore() async {
|
||||
final categoryId = _categoryId;
|
||||
final state = _state(categoryId);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final sequence = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notifyIfCurrent(categoryId);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(
|
||||
categoryId: categoryId,
|
||||
cursor: state.cursor,
|
||||
size: pageSize,
|
||||
)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(categoryId, sequence)) return;
|
||||
final seen = state.dramas.map((drama) => drama.id).toSet();
|
||||
final appended = page.dramas
|
||||
.where((drama) => seen.add(drama.id))
|
||||
.toList();
|
||||
state
|
||||
..dramas = List.unmodifiable([...state.dramas, ...appended])
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..lastError = null;
|
||||
} catch (error) {
|
||||
if (_acceptSequence(categoryId, sequence)) {
|
||||
state.lastError = error.toString();
|
||||
}
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
_notifyIfCurrent(categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refresh(String? categoryId, {required bool keepData}) {
|
||||
final state = _state(categoryId);
|
||||
final operation = _performRefresh(categoryId, keepData: keepData);
|
||||
state.inFlight = operation;
|
||||
unawaited(_clearInFlight(state, operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh(
|
||||
String? categoryId, {
|
||||
required bool keepData,
|
||||
}) async {
|
||||
final state = _state(categoryId);
|
||||
final sequence = ++state.requestSeq;
|
||||
state
|
||||
..loading = true
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(categoryId);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(categoryId: categoryId, size: pageSize)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(categoryId, sequence)) return false;
|
||||
state
|
||||
..dramas = List.unmodifiable(page.dramas)
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(categoryId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_acceptSequence(categoryId, sequence)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = error.toString();
|
||||
_notifyIfCurrent(categoryId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearInFlight(
|
||||
_CategoryFeedState state,
|
||||
Future<bool> operation,
|
||||
) async {
|
||||
await operation;
|
||||
if (identical(state.inFlight, operation)) state.inFlight = null;
|
||||
}
|
||||
|
||||
_CategoryFeedState _state(String? categoryId) =>
|
||||
_states.putIfAbsent(categoryId, _CategoryFeedState.new);
|
||||
|
||||
bool _acceptSequence(String? categoryId, int sequence) =>
|
||||
!_disposed && _state(categoryId).requestSeq == sequence;
|
||||
|
||||
void _notifyIfCurrent(String? categoryId) {
|
||||
if (!_disposed && categoryId == _categoryId) _notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
String? lastError;
|
||||
int requestSeq = 0;
|
||||
Future<bool>? inFlight;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// iOS 使用实时 viewInsets;Android adjustNothing 下取 MethodChannel 最大值。
|
||||
double effectiveKeyboardInset(
|
||||
BuildContext context,
|
||||
KeyboardInsetNotifier notifier, {
|
||||
double maxRatio = 0.6,
|
||||
}) {
|
||||
final media = MediaQuery.of(context);
|
||||
final viewInset = media.viewInsets.bottom;
|
||||
final channelInset =
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android
|
||||
? notifier.value / media.devicePixelRatio
|
||||
: 0.0;
|
||||
final inset = math.max(viewInset, channelInset);
|
||||
final maxInset = media.size.height * maxRatio;
|
||||
if (!inset.isFinite || inset > maxInset) return 0;
|
||||
return math.max(0, inset);
|
||||
}
|
||||
|
||||
/// Provides the Android IME height even when `adjustNothing` keeps
|
||||
/// `MediaQuery.viewInsets` at zero.
|
||||
class KeyboardInsetNotifier extends ChangeNotifier {
|
||||
|
||||
@@ -9,10 +9,10 @@ import '../theme/app_colors.dart';
|
||||
|
||||
const double _maxPullDistance = 96;
|
||||
const double _refreshThreshold = 56;
|
||||
const double _indicatorHeight = 64;
|
||||
const double _indicatorHeight = 56;
|
||||
const double _indicatorMaxOffset = 10;
|
||||
const Duration _arrowTurnDuration = Duration(milliseconds: 160);
|
||||
const Duration _retractDuration = Duration(milliseconds: 200);
|
||||
const Duration _settleDuration = Duration(milliseconds: 200);
|
||||
|
||||
enum _PullState { idle, pull, armed, refreshing }
|
||||
|
||||
@@ -26,7 +26,7 @@ class AppPullToRefresh extends StatefulWidget {
|
||||
});
|
||||
|
||||
static const AlwaysScrollableScrollPhysics physics =
|
||||
AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());
|
||||
AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics());
|
||||
|
||||
final Widget child;
|
||||
final Future<void> Function() onRefresh;
|
||||
@@ -36,13 +36,31 @@ class AppPullToRefresh extends StatefulWidget {
|
||||
State<AppPullToRefresh> createState() => _AppPullToRefreshState();
|
||||
}
|
||||
|
||||
class _AppPullToRefreshState extends State<AppPullToRefresh> {
|
||||
class _AppPullToRefreshState extends State<AppPullToRefresh>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _offsetController;
|
||||
_PullState _state = _PullState.idle;
|
||||
double _pullDistance = 0;
|
||||
bool _refreshing = false;
|
||||
bool _retracting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_offsetController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _settleDuration,
|
||||
upperBound: _maxPullDistance,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_offsetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _onScroll(ScrollNotification notification) {
|
||||
if (notification.depth != 0 || _refreshing) return false;
|
||||
if (notification.depth != 0 || _refreshing || _retracting) return false;
|
||||
if (notification is ScrollStartNotification) {
|
||||
if (notification.dragDetails != null) _setPullDistance(0);
|
||||
} else if (notification is ScrollUpdateNotification) {
|
||||
@@ -53,7 +71,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh> {
|
||||
}
|
||||
} else if (notification is OverscrollNotification) {
|
||||
if (notification.dragDetails != null) {
|
||||
_setPullDistance(_pullDistance - notification.overscroll);
|
||||
_setPullDistance(_offsetController.value - notification.overscroll);
|
||||
}
|
||||
} else if (notification is ScrollEndNotification) {
|
||||
_release();
|
||||
@@ -68,75 +86,104 @@ class _AppPullToRefreshState extends State<AppPullToRefresh> {
|
||||
: distance > 0
|
||||
? _PullState.pull
|
||||
: _PullState.idle;
|
||||
if (distance == _pullDistance && state == _state) return;
|
||||
setState(() {
|
||||
_pullDistance = distance;
|
||||
_state = state;
|
||||
});
|
||||
_offsetController.value = distance;
|
||||
if (state != _state) setState(() => _state = state);
|
||||
}
|
||||
|
||||
void _release() {
|
||||
if (_refreshing || _retracting) return;
|
||||
if (_state == _PullState.armed) {
|
||||
unawaited(_refresh());
|
||||
} else if (_state == _PullState.pull) {
|
||||
setState(() {
|
||||
_pullDistance = 0;
|
||||
_state = _PullState.idle;
|
||||
});
|
||||
unawaited(_retract());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retract() async {
|
||||
if (_retracting) return;
|
||||
_retracting = true;
|
||||
await _animateOffsetTo(0);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_state = _PullState.idle;
|
||||
_retracting = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
setState(() => _state = _PullState.refreshing);
|
||||
final refreshFuture = Future<void>.sync(widget.onRefresh);
|
||||
try {
|
||||
await widget.onRefresh();
|
||||
await refreshFuture;
|
||||
} finally {
|
||||
await Future<void>.delayed(_retractDuration);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pullDistance = 0;
|
||||
_state = _PullState.idle;
|
||||
_refreshing = false;
|
||||
});
|
||||
await _animateOffsetTo(0);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_state = _PullState.idle;
|
||||
_refreshing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _animateOffsetTo(double target) async {
|
||||
try {
|
||||
await _offsetController
|
||||
.animateTo(target, duration: _settleDuration, curve: Curves.easeOut)
|
||||
.orCancel;
|
||||
} on TickerCanceled {
|
||||
// The overlay can be removed while the refresh future is still pending.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Listener(
|
||||
onPointerUp: (_) => _release(),
|
||||
onPointerCancel: (_) => _release(),
|
||||
child: widget.child,
|
||||
),
|
||||
if (_state != _PullState.idle)
|
||||
Positioned(
|
||||
top: widget.edgeOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(child: _indicatorLayer(context)),
|
||||
),
|
||||
],
|
||||
child: AnimatedBuilder(
|
||||
animation: _offsetController,
|
||||
child: Listener(
|
||||
onPointerUp: (_) => _release(),
|
||||
onPointerCancel: (_) => _release(),
|
||||
child: widget.child,
|
||||
),
|
||||
builder: (context, child) {
|
||||
final offset = _offsetController.value;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Transform.translate(
|
||||
key: const Key('pull-refresh-content'),
|
||||
offset: Offset(0, offset),
|
||||
child: IgnorePointer(
|
||||
ignoring: _refreshing || _retracting,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (_state != _PullState.idle && offset > 0)
|
||||
Positioned(
|
||||
top: widget.edgeOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(child: _indicatorLayer(context, offset)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _indicatorLayer(BuildContext context) {
|
||||
final progress = (_pullDistance / _refreshThreshold).clamp(0.0, 1.0);
|
||||
final opacity = _state == _PullState.refreshing
|
||||
? 1.0
|
||||
: progress.clamp(0.0, 1.0);
|
||||
Widget _indicatorLayer(BuildContext context, double offset) {
|
||||
final progress = (offset / _refreshThreshold).clamp(0.0, 1.0);
|
||||
return SizedBox(
|
||||
height: _indicatorHeight,
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
opacity: progress,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _indicatorMaxOffset * progress),
|
||||
child: Align(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const Key _toastKey = Key('centered-toast');
|
||||
const Duration _fadeDuration = Duration(milliseconds: 200);
|
||||
const Duration _visibleDuration = Duration(seconds: 2);
|
||||
const double _toastRadius = 12;
|
||||
const double _toastMaxWidth = 320;
|
||||
const double _toastHorizontalInset = 20;
|
||||
const double _toastVerticalInset = 12;
|
||||
const double _toastBackgroundOpacity = 0.78;
|
||||
const TextStyle _toastTextStyle = TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
const EdgeInsets _toastPadding = EdgeInsets.symmetric(
|
||||
horizontal: _toastHorizontalInset,
|
||||
vertical: _toastVerticalInset,
|
||||
);
|
||||
|
||||
OverlayEntry? _activeToast;
|
||||
|
||||
/// Shows a lightweight centered message and replaces any active toast.
|
||||
void showCenteredToast(BuildContext context, String message) {
|
||||
_removeActiveToast();
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => _CenteredToast(
|
||||
message: message,
|
||||
onDismissed: () {
|
||||
if (!identical(_activeToast, entry)) return;
|
||||
_activeToast = null;
|
||||
if (entry.mounted) entry.remove();
|
||||
},
|
||||
),
|
||||
);
|
||||
_activeToast = entry;
|
||||
overlay.insert(entry);
|
||||
}
|
||||
|
||||
void _removeActiveToast() {
|
||||
final entry = _activeToast;
|
||||
_activeToast = null;
|
||||
if (entry != null && entry.mounted) entry.remove();
|
||||
}
|
||||
|
||||
class _CenteredToast extends StatefulWidget {
|
||||
const _CenteredToast({required this.message, required this.onDismissed});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onDismissed;
|
||||
|
||||
@override
|
||||
State<_CenteredToast> createState() => _CenteredToastState();
|
||||
}
|
||||
|
||||
class _CenteredToastState extends State<_CenteredToast>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _opacity = AnimationController(
|
||||
vsync: this,
|
||||
duration: _fadeDuration,
|
||||
);
|
||||
Timer? _dismissTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_opacity.forward();
|
||||
_dismissTimer = Timer(_visibleDuration, _dismiss);
|
||||
}
|
||||
|
||||
Future<void> _dismiss() async {
|
||||
try {
|
||||
await _opacity.reverse();
|
||||
} on TickerCanceled {
|
||||
return;
|
||||
}
|
||||
if (mounted) widget.onDismissed();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dismissTimer?.cancel();
|
||||
_opacity.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: _opacity,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
key: _toastKey,
|
||||
constraints: const BoxConstraints(maxWidth: _toastMaxWidth),
|
||||
padding: _toastPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(
|
||||
alpha: _toastBackgroundOpacity,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(_toastRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: _toastTextStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,7 @@
|
||||
"writeComment": "Write a comment",
|
||||
"commentLoginHint": "Sign in to comment",
|
||||
"postComment": "Post",
|
||||
"commentPostSuccess": "Comment posted",
|
||||
"loadMore": "Load more",
|
||||
"commentsLoadFailed": "Comments could not be loaded.",
|
||||
"commentPostFailed": "Could not post your comment.",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"writeComment": "Tulis komentar",
|
||||
"commentLoginHint": "Masuk untuk berkomentar",
|
||||
"postComment": "Kirim",
|
||||
"commentPostSuccess": "Komentar terkirim",
|
||||
"loadMore": "Muat lebih banyak",
|
||||
"commentsLoadFailed": "Komentar tidak dapat dimuat.",
|
||||
"commentPostFailed": "Komentar tidak dapat dikirim.",
|
||||
|
||||
@@ -946,6 +946,12 @@ abstract class AppLocalizations {
|
||||
/// **'Post'**
|
||||
String get postComment;
|
||||
|
||||
/// No description provided for @commentPostSuccess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Comment posted'**
|
||||
String get commentPostSuccess;
|
||||
|
||||
/// No description provided for @loadMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -462,6 +462,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get postComment => 'Post';
|
||||
|
||||
@override
|
||||
String get commentPostSuccess => 'Comment posted';
|
||||
|
||||
@override
|
||||
String get loadMore => 'Load more';
|
||||
|
||||
|
||||
@@ -463,6 +463,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get postComment => 'Kirim';
|
||||
|
||||
@override
|
||||
String get commentPostSuccess => 'Komentar terkirim';
|
||||
|
||||
@override
|
||||
String get loadMore => 'Muat lebih banyak';
|
||||
|
||||
|
||||
@@ -457,6 +457,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get postComment => '发布';
|
||||
|
||||
@override
|
||||
String get commentPostSuccess => '发表成功';
|
||||
|
||||
@override
|
||||
String get loadMore => '加载更多';
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"writeComment": "写评论",
|
||||
"commentLoginHint": "登录后参与评论",
|
||||
"postComment": "发布",
|
||||
"commentPostSuccess": "发表成功",
|
||||
"loadMore": "加载更多",
|
||||
"commentsLoadFailed": "评论加载失败,请重试。",
|
||||
"commentPostFailed": "评论发布失败,请重试。",
|
||||
|
||||
@@ -8,9 +8,11 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/platform/haptics.dart';
|
||||
import '../../core/platform/keyboard_inset.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/centered_toast.dart';
|
||||
import '../../core/widgets/subpage_header.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'auth_logic.dart';
|
||||
@@ -101,6 +103,9 @@ class _AuthPageState extends State<AuthPage> {
|
||||
late final TextEditingController _passwordController;
|
||||
late final FocusNode _emailFocus;
|
||||
late final FocusNode _passwordFocus;
|
||||
late final ScrollController _scrollController;
|
||||
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||
double _lastKeyboardInset = 0;
|
||||
bool _obscurePassword = true;
|
||||
bool _emailEntryOpen = false;
|
||||
|
||||
@@ -112,17 +117,87 @@ class _AuthPageState extends State<AuthPage> {
|
||||
_passwordController = TextEditingController();
|
||||
_emailFocus = FocusNode(debugLabel: 'auth-email-focus');
|
||||
_passwordFocus = FocusNode(debugLabel: 'auth-password-focus');
|
||||
_scrollController = ScrollController();
|
||||
_emailFocus.addListener(_onFocusChanged);
|
||||
_passwordFocus.addListener(_onFocusChanged);
|
||||
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||
unawaited(_keyboardInset.start());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final inset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if ((inset - _lastKeyboardInset).abs() >= 1) {
|
||||
_lastKeyboardInset = inset;
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||
_emailFocus.removeListener(_onFocusChanged);
|
||||
_passwordFocus.removeListener(_onFocusChanged);
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_emailFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKeyboardInsetChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (_emailFocus.hasFocus || _passwordFocus.hasFocus) {
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleRevealFocusedField() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _revealFocusedField();
|
||||
});
|
||||
}
|
||||
|
||||
void _revealFocusedField() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final focus = _passwordFocus.hasFocus
|
||||
? _passwordFocus
|
||||
: _emailFocus.hasFocus
|
||||
? _emailFocus
|
||||
: null;
|
||||
final fieldContext = focus?.context;
|
||||
final renderObject = fieldContext?.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if (keyboardInset <= 0) return;
|
||||
final fieldBottom = renderObject
|
||||
.localToGlobal(Offset(0, renderObject.size.height))
|
||||
.dy;
|
||||
final visibleBottom =
|
||||
MediaQuery.sizeOf(context).height - keyboardInset - 24;
|
||||
final overlap = fieldBottom - visibleBottom;
|
||||
if (overlap <= 0) return;
|
||||
final position = _scrollController.position;
|
||||
final target = (position.pixels + overlap)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
if (target <= position.pixels + 0.5) return;
|
||||
unawaited(
|
||||
_scrollController.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _errorText(AppLocalizations l10n, AuthFieldError error) {
|
||||
switch (error) {
|
||||
case AuthFieldError.emailRequired:
|
||||
@@ -153,6 +228,7 @@ class _AuthPageState extends State<AuthPage> {
|
||||
_logic.errorMessage = null;
|
||||
});
|
||||
final auth = context.read<AuthController>();
|
||||
String? failureMessage;
|
||||
try {
|
||||
if (_logic.signUpMode) {
|
||||
await auth.register(
|
||||
@@ -168,14 +244,14 @@ class _AuthPageState extends State<AuthPage> {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).maybePop();
|
||||
} on ApiException catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _logic.errorMessage = error.message);
|
||||
failureMessage = error.message;
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _logic.errorMessage = l10n.authNetworkError);
|
||||
failureMessage = l10n.authNetworkError;
|
||||
} finally {
|
||||
if (mounted) setState(() => _logic.busy = false);
|
||||
}
|
||||
if (!mounted || failureMessage == null) return;
|
||||
showCenteredToast(context, failureMessage);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -246,12 +322,13 @@ class _AuthPageState extends State<AuthPage> {
|
||||
: _topContentPadding;
|
||||
final bottom =
|
||||
_bottomContentPadding +
|
||||
MediaQuery.viewInsetsOf(context).bottom;
|
||||
effectiveKeyboardInset(context, _keyboardInset);
|
||||
final available = constraints.maxHeight - top - bottom;
|
||||
final minHeight = available > 0 ? available : 0.0;
|
||||
return AutofillGroup(
|
||||
child: SingleChildScrollView(
|
||||
key: const Key('auth-scroll'),
|
||||
controller: _scrollController,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
|
||||
@@ -9,7 +9,9 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/platform/haptics.dart';
|
||||
import '../../core/platform/keyboard_inset.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/widgets/centered_toast.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
const double _fieldHeight = 52;
|
||||
@@ -46,7 +48,10 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
late final TextEditingController _code;
|
||||
late final FocusNode _phoneFocus;
|
||||
late final FocusNode _codeFocus;
|
||||
late final ScrollController _scrollController;
|
||||
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||
Timer? _countdown;
|
||||
double _lastKeyboardInset = 0;
|
||||
bool _signUp = false;
|
||||
bool _sending = false;
|
||||
bool _submitting = false;
|
||||
@@ -61,18 +66,88 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
_code = TextEditingController();
|
||||
_phoneFocus = FocusNode(debugLabel: 'phone-auth-number');
|
||||
_codeFocus = FocusNode(debugLabel: 'phone-auth-code');
|
||||
_scrollController = ScrollController();
|
||||
_phoneFocus.addListener(_onFocusChanged);
|
||||
_codeFocus.addListener(_onFocusChanged);
|
||||
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||
unawaited(_keyboardInset.start());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final inset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if ((inset - _lastKeyboardInset).abs() >= 1) {
|
||||
_lastKeyboardInset = inset;
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||
_phoneFocus.removeListener(_onFocusChanged);
|
||||
_codeFocus.removeListener(_onFocusChanged);
|
||||
_countdown?.cancel();
|
||||
_phone.dispose();
|
||||
_code.dispose();
|
||||
_phoneFocus.dispose();
|
||||
_codeFocus.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKeyboardInsetChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (_phoneFocus.hasFocus || _codeFocus.hasFocus) {
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleRevealFocusedField() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _revealFocusedField();
|
||||
});
|
||||
}
|
||||
|
||||
void _revealFocusedField() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final focus = _codeFocus.hasFocus
|
||||
? _codeFocus
|
||||
: _phoneFocus.hasFocus
|
||||
? _phoneFocus
|
||||
: null;
|
||||
final fieldContext = focus?.context;
|
||||
final renderObject = fieldContext?.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if (keyboardInset <= 0) return;
|
||||
final fieldBottom = renderObject
|
||||
.localToGlobal(Offset(0, renderObject.size.height))
|
||||
.dy;
|
||||
final visibleBottom =
|
||||
MediaQuery.sizeOf(context).height - keyboardInset - 24;
|
||||
final overlap = fieldBottom - visibleBottom;
|
||||
if (overlap <= 0) return;
|
||||
final position = _scrollController.position;
|
||||
final target = (position.pixels + overlap)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
if (target <= position.pixels + 0.5) return;
|
||||
unawaited(
|
||||
_scrollController.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), '');
|
||||
|
||||
bool get _phoneValid => _target.length >= _phoneMinLength;
|
||||
@@ -90,6 +165,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
});
|
||||
final haptics = context.read<Haptics>();
|
||||
final auth = context.read<AuthController>();
|
||||
String? failureMessage;
|
||||
await haptics.lightImpact();
|
||||
try {
|
||||
await auth.sendOtp(
|
||||
@@ -104,12 +180,15 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
).showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
||||
_codeFocus.requestFocus();
|
||||
} on ApiException catch (error) {
|
||||
if (mounted) setState(() => _error = error.message);
|
||||
failureMessage = error.message;
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = l10n.authNetworkError);
|
||||
failureMessage = l10n.authNetworkError;
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
if (mounted && failureMessage != null) {
|
||||
showCenteredToast(context, failureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
@@ -139,6 +218,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
});
|
||||
final haptics = context.read<Haptics>();
|
||||
final auth = context.read<AuthController>();
|
||||
String? failureMessage;
|
||||
await haptics.lightImpact();
|
||||
try {
|
||||
if (_signUp) {
|
||||
@@ -148,12 +228,15 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} on ApiException catch (error) {
|
||||
if (mounted) setState(() => _error = error.message);
|
||||
failureMessage = error.message;
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = l10n.authNetworkError);
|
||||
failureMessage = l10n.authNetworkError;
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
if (mounted && failureMessage != null) {
|
||||
showCenteredToast(context, failureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
void _switchMode() {
|
||||
@@ -166,16 +249,19 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final bottom = effectiveKeyboardInset(context, _keyboardInset);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottom),
|
||||
child: Container(
|
||||
key: const Key('phone-auth-sheet'),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(_sheetRadius),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(22, 10, 22, 26),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -278,9 +364,10 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
||||
decoration: _decoration(l10n.authCodeLabel, l10n.authCodeHint).copyWith(
|
||||
suffixIcon: _sendButton(l10n),
|
||||
),
|
||||
decoration: _decoration(
|
||||
l10n.authCodeLabel,
|
||||
l10n.authCodeHint,
|
||||
).copyWith(suffixIcon: _sendButton(l10n)),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/catalog_repository.dart';
|
||||
import '../../core/data/repositories/search_repository.dart';
|
||||
import '../../core/data/services/catalog_retry.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/data/services/theater_feed_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
@@ -70,6 +72,7 @@ class DiscoverPage extends StatefulWidget {
|
||||
|
||||
class _DiscoverPageState extends State<DiscoverPage> {
|
||||
late final DiscoverLogic logic;
|
||||
late final TheaterFeedController theater;
|
||||
final ValueNotifier<bool> _headerPinned = ValueNotifier<bool>(false);
|
||||
final ScrollController _categoryScroll = ScrollController();
|
||||
final Map<String, GlobalKey> _categoryAnchors = {};
|
||||
@@ -82,13 +85,25 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = DiscoverBinding().create(search: context.read<SearchRepository>());
|
||||
theater = TheaterFeedController(context.read<CatalogRepository>());
|
||||
logic.scroll.addListener(_onScroll);
|
||||
theater.addListener(_onTheaterChanged);
|
||||
unawaited(theater.select(null));
|
||||
if (widget.focusSearch) _focusOnFirstFrame();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final pinned = logic.scroll.hasClients && logic.scroll.offset > 0;
|
||||
if (pinned != _headerPinned.value) _headerPinned.value = pinned;
|
||||
if (logic.scroll.hasClients &&
|
||||
!logic.hasSearchQuery &&
|
||||
logic.scroll.position.extentAfter < 400) {
|
||||
unawaited(theater.loadMore());
|
||||
}
|
||||
}
|
||||
|
||||
void _onTheaterChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _focusOnFirstFrame() {
|
||||
@@ -111,6 +126,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
logic.scroll.removeListener(_onScroll);
|
||||
theater.removeListener(_onTheaterChanged);
|
||||
theater.dispose();
|
||||
_categoryScroll.dispose();
|
||||
_headerPinned.dispose();
|
||||
logic.dispose();
|
||||
@@ -169,7 +186,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('discover-refresh'),
|
||||
edgeOffset: _stickyHeaderHeight + 8,
|
||||
onRefresh: categories.load,
|
||||
onRefresh: () =>
|
||||
Future.wait([categories.load(), theater.refreshCurrent()]),
|
||||
child: ListView(
|
||||
controller: logic.scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
@@ -195,7 +213,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
bool _showStickyHeader(CategoryController categories) {
|
||||
if (logic.hasSearchQuery || categories.error != null) return false;
|
||||
return !(categories.loading && categories.feed.sections.isEmpty);
|
||||
return !(categories.loading && categories.categories.isEmpty);
|
||||
}
|
||||
|
||||
Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) {
|
||||
@@ -244,7 +262,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
),
|
||||
];
|
||||
}
|
||||
if (categories.loading && categories.feed.sections.isEmpty) {
|
||||
if (categories.loading && categories.categories.isEmpty) {
|
||||
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
}
|
||||
final error = categories.error;
|
||||
@@ -269,16 +287,35 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
),
|
||||
];
|
||||
}
|
||||
return _theaterBlocks(l10n, categories);
|
||||
if (theater.loadingFirstPage) {
|
||||
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
}
|
||||
final theaterError = theater.lastError;
|
||||
if (theaterError != null && theater.dramas.isEmpty) {
|
||||
return [
|
||||
_block(
|
||||
EmptyState(
|
||||
key: const Key('home-categories-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorText(l10n, theaterError, l10n.catalogError),
|
||||
subtitle: l10n.emptyHint,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: () => unawaited(_retryTheater()),
|
||||
footer: kDebugMode
|
||||
? CatalogDebugInfo(
|
||||
error: theaterError,
|
||||
onEditEndpoint: () => unawaited(_editTheaterEndpoint()),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return _theaterBlocks(l10n);
|
||||
}
|
||||
|
||||
List<Widget> _theaterBlocks(
|
||||
AppLocalizations l10n,
|
||||
CategoryController categories,
|
||||
) {
|
||||
final dramas = logic.selectedCategoryId == null
|
||||
? categories.feed.dramas
|
||||
: categories.dramasOf(logic.selectedCategoryId!);
|
||||
List<Widget> _theaterBlocks(AppLocalizations l10n) {
|
||||
final dramas = theater.dramas;
|
||||
return [
|
||||
_toolbar(l10n),
|
||||
if (dramas.isEmpty)
|
||||
@@ -293,6 +330,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
)
|
||||
else
|
||||
DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama),
|
||||
if (theater.loadingMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
key: Key('theater-load-more'),
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -386,6 +434,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
void _selectCategory(String? categoryId) {
|
||||
logic.selectCategory(categoryId);
|
||||
unawaited(theater.select(categoryId));
|
||||
final id = categoryId ?? 'all';
|
||||
_ensuredCategoryId = id;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -523,6 +572,23 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
return retryCatalogLoad(reload: categories.load);
|
||||
}
|
||||
|
||||
Future<void> _retryTheater() async {
|
||||
await retryCatalogLoad(
|
||||
reload: () async {
|
||||
await theater.refreshCurrent();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editTheaterEndpoint() {
|
||||
return showCatalogEndpointEditor(
|
||||
context,
|
||||
reload: () async {
|
||||
await theater.refreshCurrent();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editCategoriesEndpoint(CategoryController categories) {
|
||||
return showCatalogEndpointEditor(context, reload: categories.load);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _searchHeight = 44;
|
||||
const double _searchRadius = 22;
|
||||
const double _searchRadius = 12;
|
||||
const double _searchPadding = 14;
|
||||
const double _iconSize = 18;
|
||||
const double _iconGap = 8;
|
||||
@@ -16,7 +16,7 @@ const double _clearSize = 18;
|
||||
const double _clearIconSize = 12;
|
||||
const Duration _focusTransition = Duration(milliseconds: 160);
|
||||
|
||||
/// 剧场顶部整行搜索框:浅灰胶囊,聚焦时变白并显示主题色描边。
|
||||
/// 剧场顶部整行搜索框:浅灰圆角,聚焦时变白并显示主题色描边。
|
||||
class DiscoverSearchField extends StatefulWidget {
|
||||
const DiscoverSearchField({
|
||||
super.key,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -116,6 +118,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
final saved = context.watch<FavoriteController>().isSaved(drama.id);
|
||||
final continueIndex = logic.continueIndex;
|
||||
final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty;
|
||||
final statsEpisode = episodesReady
|
||||
? drama.episodes[continueIndex != null &&
|
||||
continueIndex < drama.episodes.length
|
||||
? continueIndex
|
||||
: 0]
|
||||
: null;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
bottomNavigationBar: DramaDetailBottomBar(
|
||||
@@ -146,6 +154,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
favoriteCount: logic.adjustedFavoriteCount,
|
||||
likeCount: statsEpisode?.likeCount,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
|
||||
@@ -34,11 +34,13 @@ class DramaStatsCard extends StatelessWidget {
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
this.favoriteCount,
|
||||
this.likeCount,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final int? favoriteCount;
|
||||
final int? likeCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -46,9 +48,9 @@ class DramaStatsCard extends StatelessWidget {
|
||||
final favorite = currentFavoriteCount == null
|
||||
? l10n.episodeCountLabel(drama.episodeCount)
|
||||
: '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}';
|
||||
final like = drama.likeCount == null
|
||||
final like = likeCount == null
|
||||
? '—'
|
||||
: '${formatInteractionCount(drama.likeCount!)} ${l10n.like}';
|
||||
: '${formatInteractionCount(likeCount!)} ${l10n.like}';
|
||||
return Container(
|
||||
key: const Key('detail-stats'),
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
|
||||
@@ -16,10 +16,11 @@ 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';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../player/open_player.dart';
|
||||
import 'favorites_binding.dart';
|
||||
|
||||
const double _pageHorizontal = 14;
|
||||
@@ -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,
|
||||
@@ -194,9 +224,7 @@ class _FavoritesBody extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context, Drama drama) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
||||
);
|
||||
await playDramaFromCatalog(context, drama);
|
||||
}
|
||||
|
||||
Future<void> _remove(BuildContext context, String dramaId) async {
|
||||
@@ -227,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(
|
||||
@@ -320,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(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 首页顶栏的三个内容频道。
|
||||
enum HomeFeedChannel {
|
||||
anime('ANIME'),
|
||||
live('LIVE'),
|
||||
recommend(null);
|
||||
|
||||
const HomeFeedChannel(this.apiType);
|
||||
|
||||
final String? apiType;
|
||||
|
||||
List<String> get apiTypes => apiType == null ? const [] : <String>[apiType!];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../discover/discover_types.dart';
|
||||
@@ -22,6 +23,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
bool drawerOpen = false;
|
||||
bool detailOpen = false;
|
||||
bool videoImmersive = false;
|
||||
bool _dramaNotifyScheduled = false;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -120,7 +122,17 @@ class HomeLogic extends ChangeNotifier {
|
||||
|
||||
void onDramaChanged(Drama drama) {
|
||||
currentDrama = drama;
|
||||
notify();
|
||||
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (_dramaNotifyScheduled || _disposed) return;
|
||||
_dramaNotifyScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_dramaNotifyScheduled = false;
|
||||
if (!_disposed) notify();
|
||||
});
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
|
||||
void onImmersiveChanged(bool value) {
|
||||
|
||||
+161
-28
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/catalog_retry.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
@@ -24,8 +25,10 @@ import '../discover/discover_types.dart';
|
||||
import '../discover/discover_view.dart';
|
||||
import '../create/create_view.dart';
|
||||
import '../mine/mine_types.dart';
|
||||
import '../player/widgets/home_feed_header.dart';
|
||||
import '../player/player_view.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
import 'home_feed_channel.dart';
|
||||
import 'home_binding.dart';
|
||||
import 'home_logic.dart';
|
||||
|
||||
@@ -43,6 +46,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
static const _defaultSplashSize = 384.0;
|
||||
|
||||
bool _resumeRetryInFlight = false;
|
||||
HomeFeedController? _homeFeed;
|
||||
bool _recommendSeeded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -52,6 +57,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_homeFeed?.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -131,11 +137,25 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
return _HomeTabs(
|
||||
key: ValueKey(catalog.dramas.first.id),
|
||||
dramas: catalog.dramas,
|
||||
onRefreshFeed: catalog.refresh,
|
||||
);
|
||||
final homeFeed = _ensureHomeFeed(catalog);
|
||||
if (!_recommendSeeded) {
|
||||
_recommendSeeded = true;
|
||||
homeFeed.seedRecommend(catalog.dramas);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_refreshRecommendAndPrefetch(homeFeed));
|
||||
});
|
||||
}
|
||||
return _HomeTabs(key: const Key('home-tabs'), feedController: homeFeed);
|
||||
}
|
||||
|
||||
HomeFeedController _ensureHomeFeed(CatalogController catalog) {
|
||||
return _homeFeed ??= HomeFeedController(catalog.repository);
|
||||
}
|
||||
|
||||
Future<void> _refreshRecommendAndPrefetch(HomeFeedController homeFeed) async {
|
||||
if (await homeFeed.refreshCurrent()) {
|
||||
await homeFeed.prefetchChannels();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _startupSplash() {
|
||||
@@ -199,14 +219,9 @@ class HomeFailureView extends StatelessWidget {
|
||||
|
||||
/// 四个根页面共享同一路由并保持各自状态。
|
||||
class _HomeTabs extends StatefulWidget {
|
||||
const _HomeTabs({
|
||||
super.key,
|
||||
required this.dramas,
|
||||
required this.onRefreshFeed,
|
||||
});
|
||||
const _HomeTabs({super.key, required this.feedController});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final Future<bool> Function() onRefreshFeed;
|
||||
final HomeFeedController feedController;
|
||||
|
||||
@override
|
||||
State<_HomeTabs> createState() => _HomeTabsState();
|
||||
@@ -214,19 +229,29 @@ class _HomeTabs extends StatefulWidget {
|
||||
|
||||
class _HomeTabsState extends State<_HomeTabs> {
|
||||
late final HomeLogic logic;
|
||||
List<Drama>? _visibleDramas;
|
||||
HomeFeedChannel? _visibleChannel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = HomeBinding().create(widget.dramas);
|
||||
logic = HomeBinding().create(widget.feedController.dramas);
|
||||
_visibleDramas = widget.feedController.dramas;
|
||||
_visibleChannel = widget.feedController.channel;
|
||||
widget.feedController.addListener(_onFeedChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.feedController.removeListener(_onFeedChanged);
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFeedChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
@@ -277,24 +302,132 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
Widget _home(BuildContext context) {
|
||||
return PlayerPage(
|
||||
homeMode: true,
|
||||
embedded: true,
|
||||
active: logic.tab == 0,
|
||||
drama: widget.dramas.first,
|
||||
initialIndex: 0,
|
||||
feedDramas: widget.dramas,
|
||||
onOpenFullPlayer: (drama, episode) =>
|
||||
logic.playDrama(context, drama, episodeId: episode.id),
|
||||
onRefreshFeed: widget.onRefreshFeed,
|
||||
onDramaChanged: logic.onDramaChanged,
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
onDiscover: () => logic.select(1),
|
||||
onMy: () => logic.select(3),
|
||||
final currentDramas = widget.feedController.dramas;
|
||||
final currentChannel = widget.feedController.channel;
|
||||
if (currentDramas.isNotEmpty) {
|
||||
_visibleDramas = currentDramas;
|
||||
_visibleChannel = currentChannel;
|
||||
}
|
||||
if (currentDramas.isEmpty && !widget.feedController.loadingFirstPage) {
|
||||
return _feedChannelPlaceholder(context);
|
||||
}
|
||||
final visibleDramas = _visibleDramas;
|
||||
final visibleChannel = _visibleChannel;
|
||||
final switching =
|
||||
currentDramas.isEmpty && widget.feedController.loadingFirstPage;
|
||||
if (visibleDramas == null ||
|
||||
visibleDramas.isEmpty ||
|
||||
visibleChannel == null) {
|
||||
return _feedChannelPlaceholder(context);
|
||||
}
|
||||
final playerDramas = currentDramas.isEmpty ? visibleDramas : currentDramas;
|
||||
final playerChannel = currentDramas.isEmpty
|
||||
? visibleChannel
|
||||
: currentChannel;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
PlayerPage(
|
||||
homeMode: true,
|
||||
embedded: true,
|
||||
active: logic.tab == 0,
|
||||
drama: playerDramas.first,
|
||||
initialIndex: 0,
|
||||
feedDramas: playerDramas,
|
||||
feedChannel: playerChannel,
|
||||
headerChannel: currentChannel,
|
||||
onOpenFullPlayer: (drama, episode) =>
|
||||
logic.playDrama(context, drama, episodeId: episode.id),
|
||||
onRefreshFeed: widget.feedController.refreshCurrent,
|
||||
onLoadMore: widget.feedController.loadMore,
|
||||
onChannelSelected: _selectFeedChannel,
|
||||
onDramaChanged: logic.onDramaChanged,
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
onDiscover: () => logic.select(1),
|
||||
onMy: () => logic.select(3),
|
||||
),
|
||||
if (switching)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
key: const Key('home-feed-channel-loading'),
|
||||
child: ColoredBox(
|
||||
color: Colors.black26,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _feedChannelPlaceholder(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
HomeFeedHeader(
|
||||
l10n: l10n,
|
||||
channel: widget.feedController.channel,
|
||||
onChannelSelected: _selectFeedChannel,
|
||||
onRefresh: () => _refreshPlaceholder(context),
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
onMore: () {},
|
||||
),
|
||||
Expanded(
|
||||
child: widget.feedController.loadingFirstPage
|
||||
? const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('home-feed-channel-loading'),
|
||||
strokeWidth: 2.4,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
)
|
||||
: EmptyState(
|
||||
icon: Icons.movie_outlined,
|
||||
title: l10n.noStories,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: () =>
|
||||
unawaited(widget.feedController.refreshCurrent()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _refreshPlaceholder(BuildContext context) async {
|
||||
final ok = await widget.feedController.refreshCurrent();
|
||||
if (!ok && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _selectFeedChannel(HomeFeedChannel channel) {
|
||||
unawaited(widget.feedController.selectChannel(channel));
|
||||
}
|
||||
|
||||
Widget _discover(BuildContext context) {
|
||||
return DiscoverPage(
|
||||
active: logic.tab == 1,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
@@ -16,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';
|
||||
@@ -32,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,
|
||||
@@ -39,13 +47,19 @@ class PlayerLogic extends ChangeNotifier {
|
||||
required this.pictureInPicture,
|
||||
this.wallet,
|
||||
this.downloads,
|
||||
});
|
||||
PlaybackPositionCache? positionCache,
|
||||
Future<void> Function()? waitForFrame,
|
||||
}) : positionCache = positionCache ?? PlaybackPositionCache(),
|
||||
_waitForFrame =
|
||||
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame);
|
||||
|
||||
final WatchHistoryRepository history;
|
||||
final PlaybackRepository playback;
|
||||
final AndroidPictureInPicture pictureInPicture;
|
||||
final WalletController? wallet;
|
||||
final DownloadController? downloads;
|
||||
final PlaybackPositionCache positionCache;
|
||||
final Future<void> Function() _waitForFrame;
|
||||
|
||||
late PageController pages;
|
||||
late PlaybackQueue queue;
|
||||
@@ -100,6 +114,18 @@ class PlayerLogic extends ChangeNotifier {
|
||||
ValueChanged<Drama>? onDramaChanged;
|
||||
ValueChanged<bool>? onImmersiveChanged;
|
||||
bool _disposed = false;
|
||||
PlayerFactory? _sessionFactory;
|
||||
PlaybackCoordinator? _sessionCoordinator;
|
||||
int feedRevision = 0;
|
||||
List<Drama> _latestFeed = const [];
|
||||
String? _latestKeepDramaId;
|
||||
bool _latestResetToFirst = false;
|
||||
bool _feedUpdateRunning = false;
|
||||
bool _hasPendingFeed = false;
|
||||
bool _deferredFeedNotifyScheduled = false;
|
||||
int _feedJumpSerial = 0;
|
||||
Future<void> _feedUpdateFuture = Future<void>.value();
|
||||
Future<void>? _pendingSeek;
|
||||
|
||||
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
|
||||
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
|
||||
@@ -109,9 +135,21 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
bool get chromeHidden => immersive;
|
||||
|
||||
/// 最近一次 feed 更新流程完成;测试和页面退出可等待它结束。
|
||||
Future<void> get feedUpdateSettled => _feedUpdateFuture;
|
||||
|
||||
/// 当前 PageView 对应用户正在观看的短剧 id,尚未起播时为 null。
|
||||
String? get currentDramaId {
|
||||
if (!_started || visibleIndices.isEmpty) return null;
|
||||
final index = session.currentIndex;
|
||||
if (index < 0 || index >= queue.length) return null;
|
||||
return queue.entryAt(index).drama.id;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -145,6 +183,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
required PlaybackCoordinator coordinator,
|
||||
}) {
|
||||
_started = true;
|
||||
_sessionFactory = factory;
|
||||
_sessionCoordinator = coordinator;
|
||||
pictureInPicture.setStateListener(onPipChanged);
|
||||
queue = feedDramas == null
|
||||
? PlaybackQueue.series(pageDrama)
|
||||
@@ -153,15 +193,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
pages = PageController(initialPage: initialIndex);
|
||||
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
|
||||
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
|
||||
session = PlaybackSession(
|
||||
episodes: sessionEpisodes,
|
||||
factory: factory,
|
||||
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
|
||||
canPlay: (episode) => episode.videoUrl.isNotEmpty,
|
||||
prepare: preparePlayer,
|
||||
activate: activatePlayer,
|
||||
coordinator: coordinator,
|
||||
);
|
||||
session = _createSession(factory, coordinator);
|
||||
if (!active || visibleIndices.isEmpty) {
|
||||
session.setRouteVisible(false);
|
||||
}
|
||||
@@ -169,11 +201,184 @@ class PlayerLogic extends ChangeNotifier {
|
||||
unawaited(selectEpisode(initialIndex));
|
||||
}
|
||||
|
||||
/// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。
|
||||
void updateFeed(
|
||||
List<Drama> dramas, {
|
||||
String? keepDramaId,
|
||||
bool resetToFirst = false,
|
||||
}) {
|
||||
feedRevision++;
|
||||
pageRequest++;
|
||||
_latestFeed = List<Drama>.unmodifiable(dramas);
|
||||
_latestKeepDramaId = keepDramaId;
|
||||
_latestResetToFirst = resetToFirst;
|
||||
_hasPendingFeed = true;
|
||||
if (!_started) {
|
||||
feedDramas = _latestFeed;
|
||||
return;
|
||||
}
|
||||
if (!_feedUpdateRunning) {
|
||||
_feedUpdateFuture = _drainFeedUpdates();
|
||||
unawaited(_feedUpdateFuture);
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackSession _createSession(
|
||||
PlayerFactory factory,
|
||||
PlaybackCoordinator coordinator,
|
||||
) {
|
||||
return PlaybackSession(
|
||||
episodes: sessionEpisodes,
|
||||
factory: factory,
|
||||
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
|
||||
canPlay: (episode) => episode.videoUrl.isNotEmpty,
|
||||
prepare: preparePlayer,
|
||||
activate: activatePlayer,
|
||||
coordinator: coordinator,
|
||||
waitForFrame: _waitForFrame,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _drainFeedUpdates() async {
|
||||
_feedUpdateRunning = true;
|
||||
while (true) {
|
||||
if (!_hasPendingFeed) {
|
||||
_feedUpdateRunning = false;
|
||||
return;
|
||||
}
|
||||
_hasPendingFeed = false;
|
||||
final revision = feedRevision;
|
||||
final dramas = _latestFeed;
|
||||
final keepDramaId = _latestKeepDramaId;
|
||||
final resetToFirst = _latestResetToFirst;
|
||||
if (revision != feedRevision) continue;
|
||||
await _applyFeedUpdate(
|
||||
dramas,
|
||||
keepDramaId: keepDramaId,
|
||||
resetToFirst: resetToFirst,
|
||||
revision: revision,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyFeedUpdate(
|
||||
List<Drama> dramas, {
|
||||
required String? keepDramaId,
|
||||
required bool resetToFirst,
|
||||
required int revision,
|
||||
}) async {
|
||||
if (!_started || revision != feedRevision) return;
|
||||
final currentId = currentDramaId;
|
||||
final nextQueue = PlaybackQueue.home(dramas);
|
||||
final nextVisible = List<int>.generate(nextQueue.length, (index) => index);
|
||||
final nextEpisodes = [for (final entry in nextQueue.entries) entry.episode];
|
||||
feedDramas = dramas;
|
||||
queue = nextQueue;
|
||||
visibleIndices = nextVisible;
|
||||
if (dramas.isNotEmpty) pageDrama = dramas.first;
|
||||
if (nextQueue.length == 0) {
|
||||
session.setRouteVisible(false);
|
||||
_notifyAfterFrameIfNeeded();
|
||||
return;
|
||||
}
|
||||
final keepIndex =
|
||||
!resetToFirst && keepDramaId != null && keepDramaId == currentId
|
||||
? nextQueue.entries.indexWhere((entry) => entry.drama.id == keepDramaId)
|
||||
: -1;
|
||||
if (keepIndex >= 0 && session.active != null) {
|
||||
sessionEpisodes
|
||||
..clear()
|
||||
..addAll(nextEpisodes);
|
||||
session.remapCurrentIndex(
|
||||
keepIndex,
|
||||
preloadIndex: nextVisibleIndex(keepIndex),
|
||||
);
|
||||
_jumpToFeedPage(keepIndex);
|
||||
_notifyAfterFrameIfNeeded();
|
||||
return;
|
||||
}
|
||||
await _replaceSession(
|
||||
nextEpisodes,
|
||||
keepIndex >= 0 ? keepIndex : 0,
|
||||
revision: revision,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _replaceSession(
|
||||
List<Episode> nextEpisodes,
|
||||
int targetIndex, {
|
||||
required int revision,
|
||||
}) async {
|
||||
if (revision != feedRevision) return;
|
||||
final factory = _sessionFactory;
|
||||
final coordinator = _sessionCoordinator;
|
||||
if (factory == null || coordinator == null) return;
|
||||
unawaited(saveCurrentProgress());
|
||||
final previous = session;
|
||||
sessionEpisodes = List<Episode>.of(nextEpisodes);
|
||||
trackedPlayer = null;
|
||||
trackedEpisode = null;
|
||||
trackedDrama = null;
|
||||
final nextSession = _createSession(factory, coordinator);
|
||||
session = nextSession;
|
||||
nextSession.setRouteVisible(active && visibleIndices.isNotEmpty);
|
||||
_notifyAfterFrameIfNeeded();
|
||||
await _waitForFrame();
|
||||
if (revision != feedRevision || !identical(session, nextSession)) return;
|
||||
await previous.close();
|
||||
if (revision != feedRevision || !identical(session, nextSession)) return;
|
||||
_jumpToFeedPage(targetIndex);
|
||||
await selectEpisode(targetIndex, revision: revision);
|
||||
}
|
||||
|
||||
void _jumpToFeedPage(int index) {
|
||||
if (!_started) return;
|
||||
final serial = ++_feedJumpSerial;
|
||||
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle &&
|
||||
pages.hasClients) {
|
||||
pages.jumpToPage(index);
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_disposed || serial != _feedJumpSerial || !_started) return;
|
||||
if (pages.hasClients) pages.jumpToPage(index);
|
||||
});
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
|
||||
void _notifyAfterFrameIfNeeded() {
|
||||
if (_disposed) return;
|
||||
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (_deferredFeedNotifyScheduled) return;
|
||||
_deferredFeedNotifyScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_deferredFeedNotifyScheduled = false;
|
||||
if (!_disposed) notify();
|
||||
});
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
|
||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||
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;
|
||||
@@ -230,7 +435,12 @@ class PlayerLogic extends ChangeNotifier {
|
||||
qualities = ticket.qualities;
|
||||
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
||||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||||
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
||||
sessionEpisodes[index] = episode.withVideoUrl(
|
||||
ticket.playUrl,
|
||||
likeCount: ticket.likeCount,
|
||||
commentCount: ticket.commentCount,
|
||||
thumbnailsUrl: ticket.thumbnailsUrl,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
blockedTicket = ticket;
|
||||
@@ -328,6 +538,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
void onLifecycle(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final wasInPip = pip || pipPreparing;
|
||||
// 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位,
|
||||
// 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。
|
||||
if (pip || pipPreparing) {
|
||||
@@ -339,8 +550,17 @@ class PlayerLogic extends ChangeNotifier {
|
||||
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
|
||||
// 否则会撞上 403(契约 §1.4)
|
||||
final episode = trackedEpisode;
|
||||
final shouldAutoResume = !wasInPip && _canAutoResume(episode);
|
||||
if (episode != null && !playback.isFresh(episode.id)) {
|
||||
unawaited(selectEpisode(session.currentIndex));
|
||||
unawaited(
|
||||
_refreshTicketThenResume(
|
||||
episode,
|
||||
session.currentIndex,
|
||||
shouldAutoResume,
|
||||
),
|
||||
);
|
||||
} else if (shouldAutoResume) {
|
||||
session.setUserWantsPlay(true);
|
||||
}
|
||||
}
|
||||
session.setAppResumed(
|
||||
@@ -349,6 +569,31 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (state != AppLifecycleState.resumed) cancelScrub();
|
||||
}
|
||||
|
||||
Future<void> _refreshTicketThenResume(
|
||||
Episode episode,
|
||||
int index,
|
||||
bool shouldResume,
|
||||
) async {
|
||||
await selectEpisode(index);
|
||||
if (!shouldResume ||
|
||||
!isMounted() ||
|
||||
trackedEpisode?.id != episode.id ||
|
||||
!_canAutoResume(episode)) {
|
||||
return;
|
||||
}
|
||||
session.setUserWantsPlay(true);
|
||||
}
|
||||
|
||||
bool _canAutoResume(Episode? episode) {
|
||||
return episode != null &&
|
||||
ticketError == null &&
|
||||
blockedEpisodeId != episode.id &&
|
||||
unlockingEpisodeId == null &&
|
||||
!session.isLocked &&
|
||||
session.error == null &&
|
||||
session.canResumeAfterBackground;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
@@ -420,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),
|
||||
);
|
||||
}
|
||||
@@ -445,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;
|
||||
@@ -469,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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -521,33 +784,84 @@ 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;
|
||||
}
|
||||
return queueProgress(
|
||||
drama,
|
||||
episode,
|
||||
player.controller.value.position,
|
||||
duration: player.controller.value.duration,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectEpisode(int index) async {
|
||||
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,
|
||||
snapshot.position,
|
||||
duration: snapshot.duration,
|
||||
finished: finished,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectEpisode(int index, {int? revision}) async {
|
||||
final feedRevisionAtStart = revision ?? feedRevision;
|
||||
final request = ++pageRequest;
|
||||
cancelScrub();
|
||||
// Capture the old position synchronously, persist without blocking a swipe.
|
||||
unawaited(saveCurrentProgress());
|
||||
if (request != pageRequest || !isMounted()) return;
|
||||
if (request != pageRequest ||
|
||||
feedRevisionAtStart != feedRevision ||
|
||||
!isMounted()) {
|
||||
return;
|
||||
}
|
||||
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
|
||||
final ready = await ensureTicket(index);
|
||||
if (request != pageRequest || !isMounted()) return;
|
||||
if (request != pageRequest ||
|
||||
feedRevisionAtStart != feedRevision ||
|
||||
!isMounted()) {
|
||||
return;
|
||||
}
|
||||
if (!ready && ticketError != null) return;
|
||||
await session.select(index, preloadIndex: nextVisibleIndex(index));
|
||||
}
|
||||
@@ -587,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();
|
||||
@@ -623,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(
|
||||
@@ -637,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,
|
||||
@@ -736,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);
|
||||
@@ -797,7 +1139,11 @@ class PlayerLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
|
||||
Future<void> showComments(BuildContext context, Drama drama) async {
|
||||
Future<void> showComments(
|
||||
BuildContext context,
|
||||
Drama drama, {
|
||||
String? episodeId,
|
||||
}) async {
|
||||
session.setOverlayOpen(true);
|
||||
await session.playbackSettled;
|
||||
if (!context.mounted) {
|
||||
@@ -810,7 +1156,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
showDragHandle: false,
|
||||
builder: (_) => DramaCommentsSheet(drama: drama),
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
builder: (_) => DramaCommentsSheet(drama: drama, episodeId: episodeId),
|
||||
);
|
||||
} finally {
|
||||
session.setOverlayOpen(false);
|
||||
|
||||
@@ -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';
|
||||
@@ -22,6 +26,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../home/home_feed_channel.dart';
|
||||
import 'comments_auth.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
@@ -41,6 +46,8 @@ import '../../core/data/repositories/playback_repository.dart';
|
||||
part 'player_view_actions.dart';
|
||||
|
||||
const double _boostPlaybackSpeed = 2.0;
|
||||
const double _playerLoadingSize = 24;
|
||||
const double _playerLoadingStrokeWidth = 2;
|
||||
|
||||
class PlayerPage extends StatefulWidget {
|
||||
const PlayerPage({
|
||||
@@ -57,10 +64,14 @@ class PlayerPage extends StatefulWidget {
|
||||
this.onSearch,
|
||||
this.onMenu,
|
||||
this.feedDramas,
|
||||
this.feedChannel,
|
||||
this.headerChannel,
|
||||
this.onDramaChanged,
|
||||
this.onImmersiveChanged,
|
||||
this.onOpenFullPlayer,
|
||||
this.onRefreshFeed,
|
||||
this.onLoadMore,
|
||||
this.onChannelSelected,
|
||||
this.wallet,
|
||||
this.downloads,
|
||||
});
|
||||
@@ -77,10 +88,14 @@ class PlayerPage extends StatefulWidget {
|
||||
final VoidCallback? onSearch;
|
||||
final VoidCallback? onMenu;
|
||||
final List<Drama>? feedDramas;
|
||||
final HomeFeedChannel? feedChannel;
|
||||
final HomeFeedChannel? headerChannel;
|
||||
final ValueChanged<Drama>? onDramaChanged;
|
||||
final ValueChanged<bool>? onImmersiveChanged;
|
||||
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
|
||||
final Future<bool> Function()? onRefreshFeed;
|
||||
final Future<void> Function()? onLoadMore;
|
||||
final ValueChanged<HomeFeedChannel>? onChannelSelected;
|
||||
final WalletController? wallet;
|
||||
final DownloadController? downloads;
|
||||
|
||||
@@ -91,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;
|
||||
@@ -108,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>(),
|
||||
@@ -123,9 +145,82 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
void didUpdateWidget(covariant PlayerPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
logic.attach(widget, isMounted: () => mounted);
|
||||
final channelChanged = oldWidget.feedChannel != widget.feedChannel;
|
||||
final feedChanged =
|
||||
_feedSignature(oldWidget.feedDramas) !=
|
||||
_feedSignature(widget.feedDramas);
|
||||
if (widget.homeMode &&
|
||||
widget.feedDramas != null &&
|
||||
(feedChanged || channelChanged)) {
|
||||
final feedDramas = widget.feedDramas!;
|
||||
final keepDramaId = channelChanged ? null : logic.currentDramaId;
|
||||
final resetToFirst = channelChanged;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
logic.updateFeed(
|
||||
feedDramas,
|
||||
keepDramaId: keepDramaId,
|
||||
resetToFirst: resetToFirst,
|
||||
);
|
||||
});
|
||||
}
|
||||
logic.onPageUpdated(oldWidget);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -155,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();
|
||||
}
|
||||
@@ -205,13 +302,22 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openComments(BuildContext context, Drama drama) async {
|
||||
Future<void> _openComments(
|
||||
BuildContext context,
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
) async {
|
||||
final comments = context.read<CommentRepository>();
|
||||
await openCommentsWithAuth(context, drama, show: logic.showComments);
|
||||
await openCommentsWithAuth(
|
||||
context,
|
||||
drama,
|
||||
show: (context, drama) =>
|
||||
logic.showComments(context, drama, episodeId: episode.id),
|
||||
);
|
||||
try {
|
||||
final count = await comments.count(drama.id);
|
||||
final count = await comments.count(drama.id, episodeId: episode.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _commentCounts[drama.id] = count);
|
||||
setState(() => _commentCounts[episode.id] = count);
|
||||
} catch (_) {
|
||||
// 评论面板已经关闭,计数刷新失败时保留旧值即可。
|
||||
}
|
||||
@@ -241,6 +347,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final index = indices[position];
|
||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||
unawaited(logic.selectEpisode(index));
|
||||
if (position >= indices.length - 3) {
|
||||
final loadMore = widget.onLoadMore;
|
||||
if (loadMore != null) unawaited(loadMore());
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, position) =>
|
||||
_buildEpisode(context, indices[position], interactions),
|
||||
@@ -259,6 +369,11 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
bottom: false,
|
||||
child: HomeFeedHeader(
|
||||
l10n: l10n,
|
||||
channel:
|
||||
widget.headerChannel ??
|
||||
widget.feedChannel ??
|
||||
HomeFeedChannel.recommend,
|
||||
onChannelSelected: widget.onChannelSelected ?? (_) {},
|
||||
onRefresh: () async {
|
||||
final ok = await widget.onRefreshFeed?.call() ?? true;
|
||||
if (context.mounted && !ok) {
|
||||
@@ -311,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.
|
||||
@@ -323,6 +441,15 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final videoPort = port is VideoPlayerPort ? port : null;
|
||||
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,
|
||||
@@ -351,9 +478,13 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
// 票据三态之一:起播前正在向后端要签名地址
|
||||
if (selected && logic.ticketLoading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('player-ticket-loading'),
|
||||
color: AppColors.blue,
|
||||
child: SizedBox.square(
|
||||
dimension: _playerLoadingSize,
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('player-ticket-loading'),
|
||||
color: AppColors.blue,
|
||||
strokeWidth: _playerLoadingStrokeWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
|
||||
@@ -384,13 +515,6 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected && logic.session.loading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('player-session-loading'),
|
||||
color: AppColors.blue,
|
||||
),
|
||||
),
|
||||
if (selected && !ready && logic.session.error != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
@@ -450,24 +574,26 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
locked: locked,
|
||||
visible: _chromeVisible,
|
||||
boosting: _boosting,
|
||||
onBoostStart: _startBoost,
|
||||
onBoostEnd: _endBoost,
|
||||
onTogglePlay: () =>
|
||||
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||
liked: interactions.isLiked(drama.id),
|
||||
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount,
|
||||
liked: interactions.isEpisodeLiked(episode.id),
|
||||
likeCount:
|
||||
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
||||
favoriteCount: drama.favoriteCount == null
|
||||
? null
|
||||
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
|
||||
commentCount: _commentCounts[drama.id] ?? drama.commentCount,
|
||||
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final result = await interactions.toggleDramaLike(drama.id);
|
||||
final result = await interactions.toggleEpisodeLike(episode.id);
|
||||
if (!result.isSuccess && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -481,7 +607,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
}
|
||||
},
|
||||
onSave: () => unawaited(_toggleSave(context, drama)),
|
||||
onComments: () => unawaited(_openComments(context, drama)),
|
||||
onComments: () => unawaited(_openComments(context, drama, episode)),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onDetail: () => unawaited(logic.openDetail(context)),
|
||||
onOpenFullPlayer: () async {
|
||||
@@ -494,6 +620,8 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
thumbnailService: _thumbnailService,
|
||||
spriteAxisService: _spriteAxisService,
|
||||
)
|
||||
else
|
||||
PlayerPlaybackOverlay(
|
||||
@@ -502,9 +630,12 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: interactions.isLiked(drama.id),
|
||||
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount,
|
||||
commentCount: _commentCounts[drama.id] ?? drama.commentCount,
|
||||
locked: locked,
|
||||
liked: interactions.isEpisodeLiked(episode.id),
|
||||
likeCount:
|
||||
interactions.episodeLikeCount(episode.id) ??
|
||||
episode.likeCount,
|
||||
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
||||
favoriteCount: drama.favoriteCount == null
|
||||
? null
|
||||
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
|
||||
@@ -513,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
|
||||
@@ -525,9 +658,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onDetail: () => unawaited(logic.openDetail(context)),
|
||||
onSpeed: () => unawaited(_openSpeedSheet(context)),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onComments: () => unawaited(_openComments(context, drama)),
|
||||
onComments: () =>
|
||||
unawaited(_openComments(context, drama, episode)),
|
||||
onLike: () =>
|
||||
unawaited(_toggleLike(context, interactions, drama)),
|
||||
unawaited(_toggleLike(context, interactions, episode)),
|
||||
onSave: () => unawaited(_toggleSave(context, drama)),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onMore: () => _openMore(context, episode),
|
||||
|
||||
@@ -81,11 +81,11 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
Future<void> _toggleLike(
|
||||
BuildContext context,
|
||||
InteractionController interactions,
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
) async {
|
||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final result = await interactions.toggleDramaLike(drama.id);
|
||||
final result = await interactions.toggleEpisodeLike(episode.id);
|
||||
if (result.isSuccess || !context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/services/auth_controller.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/platform/haptics.dart';
|
||||
import '../../../core/platform/keyboard_inset.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/auth_gate.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _sheetRadius = 20;
|
||||
const double _inputRadius = 12;
|
||||
const double _postButtonHeight = 32;
|
||||
const int _maxCommentLength = 300;
|
||||
const Duration _keyboardSettleDelay = Duration(milliseconds: 80);
|
||||
const double _maxKeyboardInsetRatio = 0.6;
|
||||
|
||||
/// 独立评论输入弹窗,复用原评论控制器的发布与列表更新能力。
|
||||
class CommentComposerSheet extends StatefulWidget {
|
||||
const CommentComposerSheet({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onDismiss,
|
||||
required this.onPosted,
|
||||
this.replyTo,
|
||||
});
|
||||
|
||||
final CommentController controller;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onPosted;
|
||||
final DramaComment? replyTo;
|
||||
|
||||
@override
|
||||
State<CommentComposerSheet> createState() => _CommentComposerSheetState();
|
||||
}
|
||||
|
||||
class _CommentComposerSheetState extends State<CommentComposerSheet> {
|
||||
final _input = TextEditingController();
|
||||
final _inputFocus = FocusNode();
|
||||
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||
Timer? _keyboardSettleTimer;
|
||||
double _pinnedKeyboardInset = 0;
|
||||
int _insetBaseline = 0;
|
||||
bool _pinnedInitialized = false;
|
||||
bool _insetReady = false;
|
||||
bool _useChannelInset = false;
|
||||
bool _keyboardVisible = false;
|
||||
|
||||
bool get _isAndroid =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_input.addListener(_onInputChanged);
|
||||
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
unawaited(_prepareInput());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_isAndroid) return;
|
||||
if (!_pinnedInitialized) {
|
||||
_pinnedInitialized = true;
|
||||
_pinnedKeyboardInset =
|
||||
_normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0;
|
||||
}
|
||||
_trackKeyboardInset(_keyboardHeight(context));
|
||||
}
|
||||
|
||||
Future<void> _prepareInput() async {
|
||||
if (_isAndroid) {
|
||||
await _keyboardInset.start();
|
||||
if (!mounted) return;
|
||||
_insetBaseline = _keyboardInset.value;
|
||||
_insetReady = true;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_inputFocus.requestFocus();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _keyboardHeight(context) > 0) return;
|
||||
unawaited(
|
||||
SystemChannels.textInput.invokeMethod<void>('TextInput.show'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_input.removeListener(_onInputChanged);
|
||||
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
_keyboardSettleTimer?.cancel();
|
||||
_inputFocus.unfocus();
|
||||
_input.dispose();
|
||||
_inputFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onInputChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onKeyboardInsetChanged() {
|
||||
if (!_isAndroid || !mounted || !_insetReady) return;
|
||||
if (_keyboardInset.value != _insetBaseline) {
|
||||
_useChannelInset = true;
|
||||
}
|
||||
_trackKeyboardInset(_keyboardHeight(context));
|
||||
}
|
||||
|
||||
double _keyboardHeight(BuildContext context) {
|
||||
final viewInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
if (!_isAndroid) {
|
||||
return _normalizeKeyboardInset(viewInset) ?? 0;
|
||||
}
|
||||
final channelInset = _useChannelInset
|
||||
? _keyboardInset.value / MediaQuery.devicePixelRatioOf(context)
|
||||
: 0.0;
|
||||
return math.max(viewInset, channelInset);
|
||||
}
|
||||
|
||||
double? _normalizeKeyboardInset(double inset) {
|
||||
final maxInset = MediaQuery.sizeOf(context).height * _maxKeyboardInsetRatio;
|
||||
if (!inset.isFinite || inset > maxInset) return null;
|
||||
return math.max(0, inset).roundToDouble();
|
||||
}
|
||||
|
||||
void _trackKeyboardInset(double inset) {
|
||||
if (!_isAndroid) return;
|
||||
final visible = inset > 0;
|
||||
if (visible == _keyboardVisible) return;
|
||||
_keyboardVisible = visible;
|
||||
_keyboardSettleTimer?.cancel();
|
||||
_keyboardSettleTimer = null;
|
||||
if (!visible) return;
|
||||
_keyboardSettleTimer = Timer(
|
||||
_keyboardSettleDelay,
|
||||
_captureSettledKeyboardInset,
|
||||
);
|
||||
}
|
||||
|
||||
void _captureSettledKeyboardInset() {
|
||||
if (!_isAndroid) return;
|
||||
_keyboardSettleTimer = null;
|
||||
if (!mounted) return;
|
||||
final inset = _keyboardHeight(context);
|
||||
if (inset <= 0) {
|
||||
_keyboardVisible = false;
|
||||
return;
|
||||
}
|
||||
final next = _normalizeKeyboardInset(inset);
|
||||
if (next == null || next == _pinnedKeyboardInset) return;
|
||||
setState(() => _pinnedKeyboardInset = next);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (widget.controller.submitting) return;
|
||||
final text = _input.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final result = await widget.controller.publish(
|
||||
text,
|
||||
replyTo: widget.replyTo,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (result.isSuccess) {
|
||||
_input.clear();
|
||||
widget.onPosted();
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.errorMessage ?? l10n.commentPostFailed)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _authenticate() async {
|
||||
await ensureSignedIn(context);
|
||||
if (!mounted || !context.read<AuthController>().isSignedIn) return;
|
||||
_inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
final hasContent = _input.text.trim().isNotEmpty;
|
||||
final canPost = signedIn && hasContent && !widget.controller.submitting;
|
||||
final multiline = _input.text.contains('\n');
|
||||
final bottomInset = _isAndroid
|
||||
? _pinnedKeyboardInset
|
||||
: _normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0;
|
||||
|
||||
final content = Padding(
|
||||
key: const Key('comment-composer-sheet'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: TextFieldTapRegion(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.replyTo != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
l10n.replyingTo(
|
||||
widget.replyTo!.nickname.isEmpty
|
||||
? l10n.anonymousUser
|
||||
: widget.replyTo!.nickname,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.commentInput,
|
||||
borderRadius: BorderRadius.circular(_inputRadius),
|
||||
),
|
||||
child: TextField(
|
||||
key: const Key('comment-composer-input'),
|
||||
controller: _input,
|
||||
focusNode: _inputFocus,
|
||||
readOnly: !signedIn,
|
||||
canRequestFocus: signedIn,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
maxLength: _maxCommentLength,
|
||||
textAlignVertical: multiline
|
||||
? TextAlignVertical.top
|
||||
: TextAlignVertical.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 15,
|
||||
height: multiline ? 1.4 : null,
|
||||
),
|
||||
cursorColor: AppColors.blue,
|
||||
decoration: InputDecoration(
|
||||
hintText: signedIn
|
||||
? l10n.writeComment
|
||||
: l10n.commentLoginHint,
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
),
|
||||
counterText: '',
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
onTap: signedIn ? null : _authenticate,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
height: _postButtonHeight,
|
||||
child: TextButton(
|
||||
key: const Key('comment-composer-post'),
|
||||
onPressed: canPost ? _submit : null,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, _postButtonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: hasContent
|
||||
? AppColors.blue.withValues(alpha: 0.12)
|
||||
: AppColors.raised,
|
||||
foregroundColor: AppColors.blue,
|
||||
disabledForegroundColor: AppColors.muted,
|
||||
disabledBackgroundColor: AppColors.raised,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.postComment,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return FocusScope(
|
||||
key: const Key('comment-composer-focus-scope'),
|
||||
autofocus: true,
|
||||
child: Stack(
|
||||
key: const Key('comment-composer-overlay'),
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const Key('comment-composer-dismiss-layer'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onDismiss,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: Material(
|
||||
key: const Key('comment-composer-panel'),
|
||||
color: AppColors.card,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(_sheetRadius),
|
||||
),
|
||||
child: AnimatedPadding(
|
||||
key: const Key('comment-composer-keyboard-padding'),
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: _isAndroid
|
||||
? content
|
||||
: SafeArea(top: false, child: content),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,23 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/repositories/comment_repository.dart';
|
||||
import '../../../core/data/repositories/interaction_repository.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/data/services/auth_controller.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/formatting/error_text.dart';
|
||||
import '../../../core/platform/haptics.dart';
|
||||
import '../../../core/platform/keyboard_inset.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/auth_gate.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'comment_composer_sheet.dart';
|
||||
import 'comment_tile.dart';
|
||||
|
||||
const double _minSheetHeight = 280;
|
||||
const double _maxSheetHeight = 520;
|
||||
const int _maxCommentLength = 300;
|
||||
const double _composerRadius = 22;
|
||||
const double _composerHeight = 44;
|
||||
const double _sendButtonSize = 38;
|
||||
@@ -31,46 +28,36 @@ const double _sheetRadius = 20;
|
||||
const double _headerHeight = 44;
|
||||
const double _replyIndent = 52;
|
||||
const double _composerTopGap = 8;
|
||||
const double _replyBannerHeight = 46;
|
||||
const double _expandedInputRadius = 12;
|
||||
const double _postButtonHeight = 32;
|
||||
const double _expandedComposerReserve = 113;
|
||||
|
||||
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。
|
||||
class DramaCommentsSheet extends StatefulWidget {
|
||||
const DramaCommentsSheet({super.key, required this.drama});
|
||||
const DramaCommentsSheet({super.key, required this.drama, this.episodeId});
|
||||
|
||||
final Drama drama;
|
||||
final String? episodeId;
|
||||
|
||||
@override
|
||||
State<DramaCommentsSheet> createState() => _DramaCommentsSheetState();
|
||||
}
|
||||
|
||||
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
final _input = TextEditingController();
|
||||
final _inputFocus = FocusNode();
|
||||
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||
final _sheetMeasureKey = GlobalKey();
|
||||
late final CommentRepository _comments;
|
||||
late final CommentController _controller;
|
||||
DramaComment? _replyTo;
|
||||
OverlayEntry? _composerOverlay;
|
||||
OverlayEntry? _successOverlay;
|
||||
Timer? _successOverlayTimer;
|
||||
int? _serverCount;
|
||||
int _countRequest = 0;
|
||||
double? _sheetHeightWithoutKeyboard;
|
||||
bool _sheetMeasureScheduled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_inputFocus.addListener(_onFocusChanged);
|
||||
_input.addListener(_onInputChanged);
|
||||
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||
unawaited(_keyboardInset.start());
|
||||
_comments = context.read<CommentRepository>();
|
||||
_controller = CommentController(
|
||||
comments: _comments,
|
||||
interactions: context.read<InteractionRepository>(),
|
||||
dramaId: widget.drama.id,
|
||||
episodeId: widget.episodeId,
|
||||
);
|
||||
unawaited(_controller.load());
|
||||
unawaited(_refreshCount());
|
||||
@@ -78,92 +65,24 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||
_inputFocus.removeListener(_onFocusChanged);
|
||||
_input.removeListener(_onInputChanged);
|
||||
_removeComposerOverlay();
|
||||
_successOverlayTimer?.cancel();
|
||||
_successOverlay?.remove();
|
||||
_controller.dispose();
|
||||
_input.dispose();
|
||||
_inputFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onInputChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onKeyboardInsetChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
double _keyboardHeight(BuildContext context) => math.max(
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
_keyboardInset.value / MediaQuery.devicePixelRatioOf(context),
|
||||
);
|
||||
|
||||
void _measureSheetWithoutKeyboard(double keyboardHeight) {
|
||||
if (keyboardHeight > 0 || _sheetMeasureScheduled) return;
|
||||
_sheetMeasureScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_sheetMeasureScheduled = false;
|
||||
if (!mounted) return;
|
||||
if (_keyboardHeight(context) > 0) return;
|
||||
final box = _sheetMeasureKey.currentContext?.findRenderObject();
|
||||
if (box is! RenderBox || !box.hasSize) return;
|
||||
final height = box.size.height;
|
||||
if (!height.isFinite ||
|
||||
(_sheetHeightWithoutKeyboard != null &&
|
||||
(height - _sheetHeightWithoutKeyboard!).abs() < 0.5)) {
|
||||
return;
|
||||
}
|
||||
setState(() => _sheetHeightWithoutKeyboard = height);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _post() async {
|
||||
if (_controller.submitting) return;
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
if (_input.text.trim().isEmpty) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final result = await _controller.publish(_input.text, replyTo: _replyTo);
|
||||
if (!mounted) return;
|
||||
if (result.isSuccess) {
|
||||
_input.clear();
|
||||
setState(() => _replyTo = null);
|
||||
_inputFocus.requestFocus();
|
||||
await _refreshCount();
|
||||
return;
|
||||
}
|
||||
_showMessage(
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
|
||||
);
|
||||
}
|
||||
|
||||
void _beginReply(DramaComment comment) {
|
||||
setState(() => _replyTo = comment);
|
||||
_inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _cancelReply() {
|
||||
if (_replyTo == null) return;
|
||||
setState(() => _replyTo = null);
|
||||
}
|
||||
|
||||
Future<void> _authenticateForComment() async {
|
||||
_inputFocus.unfocus();
|
||||
await ensureSignedIn(context);
|
||||
if (!mounted || !context.read<AuthController>().isSignedIn) return;
|
||||
_inputFocus.requestFocus();
|
||||
unawaited(_openComposer(replyTo: comment));
|
||||
}
|
||||
|
||||
Future<void> _refreshCount() async {
|
||||
final request = ++_countRequest;
|
||||
try {
|
||||
final count = await _comments.count(widget.drama.id);
|
||||
final count = await _comments.count(
|
||||
widget.drama.id,
|
||||
episodeId: widget.episodeId,
|
||||
);
|
||||
if (!mounted || request != _countRequest) return;
|
||||
setState(() => _serverCount = count);
|
||||
} catch (_) {
|
||||
@@ -172,83 +91,139 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showMessage(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
Future<void> _openComposer({DramaComment? replyTo}) async {
|
||||
await ensureSignedIn(context);
|
||||
if (!mounted || !context.read<AuthController>().isSignedIn) return;
|
||||
_removeComposerOverlay();
|
||||
final entry = OverlayEntry(
|
||||
builder: (_) => CommentComposerSheet(
|
||||
controller: _controller,
|
||||
replyTo: replyTo,
|
||||
onDismiss: _closeComposer,
|
||||
onPosted: _handleComposerPosted,
|
||||
),
|
||||
);
|
||||
setState(() => _composerOverlay = entry);
|
||||
Overlay.of(context, rootOverlay: true).insert(entry);
|
||||
}
|
||||
|
||||
void _handleComposerPosted() {
|
||||
_closeComposer();
|
||||
_showPostSuccess();
|
||||
unawaited(_refreshCount());
|
||||
}
|
||||
|
||||
void _closeComposer() {
|
||||
if (!mounted) {
|
||||
_removeComposerOverlay();
|
||||
return;
|
||||
}
|
||||
setState(_removeComposerOverlay);
|
||||
}
|
||||
|
||||
void _removeComposerOverlay() {
|
||||
final entry = _composerOverlay;
|
||||
_composerOverlay = null;
|
||||
if (entry?.mounted ?? false) entry!.remove();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
void _handleDismissLayerTap() {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _showPostSuccess() {
|
||||
_successOverlayTimer?.cancel();
|
||||
_successOverlay?.remove();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entry = OverlayEntry(
|
||||
builder: (_) =>
|
||||
_CommentPostSuccessOverlay(message: l10n.commentPostSuccess),
|
||||
);
|
||||
_successOverlay = entry;
|
||||
Overlay.of(context, rootOverlay: true).insert(entry);
|
||||
_successOverlayTimer = Timer(const Duration(milliseconds: 1200), () {
|
||||
if (entry.mounted) entry.remove();
|
||||
if (identical(_successOverlay, entry)) _successOverlay = null;
|
||||
});
|
||||
}
|
||||
|
||||
double _sheetHeight(BuildContext context) {
|
||||
final screenHeight = MediaQuery.sizeOf(context).height;
|
||||
final topSafeArea = MediaQuery.paddingOf(context).top;
|
||||
final halfHeight = (screenHeight * 0.5).roundToDouble();
|
||||
final maxHeight = math.max(0.0, screenHeight - topSafeArea);
|
||||
return math.min(halfHeight, maxHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenLimit = MediaQuery.sizeOf(context).height - 24;
|
||||
final maxHeight = screenLimit < _maxSheetHeight
|
||||
? screenLimit
|
||||
: _maxSheetHeight;
|
||||
final focused = _inputFocus.hasFocus;
|
||||
final keyboardHeight = _keyboardHeight(context);
|
||||
_measureSheetWithoutKeyboard(keyboardHeight);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: focused ? _inputFocus.unfocus : null,
|
||||
child: SizedBox(
|
||||
key: _sheetMeasureKey,
|
||||
child: Container(
|
||||
key: const Key('player-comments-sheet'),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(_sheetRadius),
|
||||
),
|
||||
final sheetHeight = _sheetHeight(context);
|
||||
final content = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const Key('player-comments-dismiss-layer'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleDismissLayerTap,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final minHeight = _controller.comments.isEmpty
|
||||
? (_minSheetHeight < maxHeight
|
||||
? _minSheetHeight
|
||||
: maxHeight)
|
||||
: 0.0;
|
||||
final pinnedHeight =
|
||||
keyboardHeight > 0 && _sheetHeightWithoutKeyboard != null
|
||||
? math.min(_sheetHeightWithoutKeyboard!, maxHeight)
|
||||
: null;
|
||||
return ConstrainedBox(
|
||||
key: const Key('comment-sheet-constraints'),
|
||||
constraints: pinnedHeight == null
|
||||
? BoxConstraints(
|
||||
minHeight: minHeight,
|
||||
maxHeight: maxHeight,
|
||||
)
|
||||
: BoxConstraints.tightFor(height: pinnedHeight),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _content(
|
||||
context,
|
||||
focused: focused,
|
||||
keyboardHeight: keyboardHeight,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: KeyedSubtree(
|
||||
key: const Key('player-comments-sheet'),
|
||||
child: SizedBox(
|
||||
key: const Key('comment-sheet-constraints'),
|
||||
height: sheetHeight,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(_sheetRadius),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _content(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return PopScope(
|
||||
canPop: _composerOverlay == null,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && _composerOverlay != null) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
unawaited(
|
||||
SystemChannels.textInput.invokeMethod<void>('TextInput.hide'),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(
|
||||
BuildContext context, {
|
||||
required bool focused,
|
||||
required double keyboardHeight,
|
||||
}) {
|
||||
Widget _content(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final count = _serverCount ?? _controller.exactCount;
|
||||
final composerReserve = focused
|
||||
? _expandedComposerReserve
|
||||
: _composerHeight + _composerTopGap;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
final composerReserve = _composerHeight + _composerTopGap;
|
||||
return Column(
|
||||
key: const Key('comment-sheet-content'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -290,37 +265,17 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: _list(
|
||||
l10n,
|
||||
composerReserve: composerReserve,
|
||||
keyboardHeight: keyboardHeight,
|
||||
),
|
||||
),
|
||||
if (_replyTo != null) _replyBanner(l10n, _replyTo!),
|
||||
AnimatedContainer(
|
||||
key: const Key('comment-input-lift'),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
transform: Matrix4.translationValues(0, -keyboardHeight, 0),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: focused ? 12 : _composerTopGap,
|
||||
bottom: focused ? 12 : 0,
|
||||
),
|
||||
child: _composer(l10n, focused: focused),
|
||||
),
|
||||
Expanded(child: _list(l10n, composerReserve: composerReserve)),
|
||||
Padding(
|
||||
key: const Key('comment-composer-area'),
|
||||
padding: const EdgeInsets.only(top: _composerTopGap),
|
||||
child: _fakeComposer(l10n, signedIn: signedIn),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list(
|
||||
AppLocalizations l10n, {
|
||||
required double composerReserve,
|
||||
required double keyboardHeight,
|
||||
}) {
|
||||
Widget _list(AppLocalizations l10n, {required double composerReserve}) {
|
||||
if (_controller.loading && _controller.comments.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
@@ -356,11 +311,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
}
|
||||
return ListView.builder(
|
||||
key: const Key('comment-list'),
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: composerReserve + keyboardHeight,
|
||||
),
|
||||
padding: EdgeInsets.only(top: 4, bottom: composerReserve),
|
||||
itemCount: _controller.comments.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _controller.comments.length) return _footer(l10n);
|
||||
@@ -390,179 +341,50 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
return const SizedBox(height: 8);
|
||||
}
|
||||
|
||||
Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => SizedBox(
|
||||
height: _replyBannerHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.replyingTo(
|
||||
replyTo.nickname.isEmpty
|
||||
? l10n.anonymousUser
|
||||
: replyTo.nickname,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('comment-cancel-reply'),
|
||||
tooltip: l10n.cancelReply,
|
||||
onPressed: _cancelReply,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
InputDecoration _composerDecoration(String hint, {required bool focused}) {
|
||||
final radius = BorderRadius.circular(
|
||||
focused ? _expandedInputRadius : _composerRadius,
|
||||
);
|
||||
final clear = OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide.none,
|
||||
);
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
|
||||
counterText: '',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: focused ? 12 : 0,
|
||||
),
|
||||
isDense: true,
|
||||
border: clear,
|
||||
enabledBorder: clear,
|
||||
focusedBorder: clear,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _composer(AppLocalizations l10n, {required bool focused}) {
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
final hasContent = _input.text.trim().isNotEmpty;
|
||||
final canPost = signedIn && hasContent && !_controller.submitting;
|
||||
return Container(
|
||||
key: const Key('comment-composer'),
|
||||
padding: focused ? EdgeInsets.zero : const EdgeInsets.only(right: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: focused ? Colors.transparent : AppColors.commentInput,
|
||||
borderRadius: BorderRadius.circular(focused ? 0 : _composerRadius),
|
||||
),
|
||||
child: TextFieldTapRegion(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
Widget _fakeComposer(AppLocalizations l10n, {required bool signedIn}) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _openComposer,
|
||||
child: Container(
|
||||
key: const Key('comment-input-placeholder'),
|
||||
height: _composerHeight,
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.commentInput,
|
||||
borderRadius: BorderRadius.circular(_composerRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
key: const Key('comment-input-surface'),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
color: focused ? AppColors.commentInput : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(
|
||||
focused ? _expandedInputRadius : _composerRadius,
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: focused ? null : _composerHeight,
|
||||
child: Row(
|
||||
crossAxisAlignment: focused
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: focused ? null : _composerHeight,
|
||||
child: TextField(
|
||||
key: const Key('comment-input'),
|
||||
controller: _input,
|
||||
focusNode: _inputFocus,
|
||||
readOnly: !signedIn,
|
||||
canRequestFocus: signedIn,
|
||||
minLines: 1,
|
||||
maxLines: focused ? 4 : 1,
|
||||
maxLength: _maxCommentLength,
|
||||
textAlignVertical: focused
|
||||
? TextAlignVertical.top
|
||||
: TextAlignVertical.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: focused ? 15 : 14,
|
||||
),
|
||||
cursorColor: AppColors.blue,
|
||||
decoration: _composerDecoration(
|
||||
signedIn
|
||||
? l10n.writeComment
|
||||
: l10n.commentLoginHint,
|
||||
focused: focused,
|
||||
),
|
||||
onTap: signedIn ? null : _authenticateForComment,
|
||||
onTapOutside: (_) => _inputFocus.unfocus(),
|
||||
onSubmitted: focused ? null : (_) => _post(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!focused)
|
||||
SizedBox.square(
|
||||
dimension: _sendButtonSize,
|
||||
child: IconButton.filled(
|
||||
key: const Key('comment-post'),
|
||||
tooltip: l10n.postComment,
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 18,
|
||||
onPressed: _controller.submitting ? null : _post,
|
||||
icon: _controller.submitting
|
||||
? const SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (focused) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
height: _postButtonHeight,
|
||||
child: TextButton(
|
||||
key: const Key('comment-post'),
|
||||
onPressed: canPost ? _post : null,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, _postButtonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: hasContent
|
||||
? AppColors.blue.withValues(alpha: 0.12)
|
||||
: AppColors.raised,
|
||||
foregroundColor: AppColors.blue,
|
||||
disabledForegroundColor: AppColors.muted,
|
||||
disabledBackgroundColor: AppColors.raised,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.postComment,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Text(
|
||||
signedIn ? l10n.writeComment : l10n.commentLoginHint,
|
||||
key: const Key('comment-input-placeholder-label'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox.square(
|
||||
dimension: _sendButtonSize,
|
||||
child: IconButton.filled(
|
||||
key: const Key('comment-post'),
|
||||
tooltip: l10n.postComment,
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 18,
|
||||
onPressed: _openComposer,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -570,6 +392,44 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentPostSuccessOverlay extends StatelessWidget {
|
||||
const _CommentPostSuccessOverlay({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: SafeArea(
|
||||
child: Align(
|
||||
alignment: const Alignment(0, 0.55),
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Container(
|
||||
key: const Key('comment-post-success-snackbar'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentThread extends StatelessWidget {
|
||||
const _CommentThread({
|
||||
required this.comment,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../home/home_feed_channel.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _topIconSize = 36;
|
||||
@@ -16,12 +17,16 @@ class HomeFeedHeader extends StatefulWidget {
|
||||
const HomeFeedHeader({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.channel,
|
||||
required this.onChannelSelected,
|
||||
required this.onRefresh,
|
||||
required this.onSearch,
|
||||
required this.onMore,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final HomeFeedChannel channel;
|
||||
final ValueChanged<HomeFeedChannel> onChannelSelected;
|
||||
final Future<bool> Function() onRefresh;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onMore;
|
||||
@@ -31,7 +36,6 @@ class HomeFeedHeader extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeFeedHeaderState extends State<HomeFeedHeader> {
|
||||
_FeedTab _tab = _FeedTab.recommend;
|
||||
bool _refreshing = false;
|
||||
|
||||
@override
|
||||
@@ -54,8 +58,12 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < _FeedTab.values.length; index++)
|
||||
_tabSlot(_FeedTab.values[index], index),
|
||||
for (
|
||||
var index = 0;
|
||||
index < HomeFeedChannel.values.length;
|
||||
index++
|
||||
)
|
||||
_tabSlot(HomeFeedChannel.values[index], index),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -74,15 +82,15 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabSlot(_FeedTab tab, int index) {
|
||||
Widget _tabSlot(HomeFeedChannel channel, int index) {
|
||||
final item = _FeedTabItem(
|
||||
tab: tab,
|
||||
label: tab.label(widget.l10n),
|
||||
selected: _tab == tab,
|
||||
channel: channel,
|
||||
label: _label(channel),
|
||||
selected: widget.channel == channel,
|
||||
fontSize: _tabFontSize,
|
||||
underlineWidth: _underlineWidth,
|
||||
underlineHeight: _underlineHeight,
|
||||
onTap: () => _selectTab(tab),
|
||||
onTap: () => widget.onChannelSelected(channel),
|
||||
);
|
||||
if (index == 0) return item;
|
||||
return Padding(
|
||||
@@ -91,14 +99,6 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
|
||||
);
|
||||
}
|
||||
|
||||
void _selectTab(_FeedTab tab) {
|
||||
if (tab.comingSoon) {
|
||||
_showComingSoon();
|
||||
return;
|
||||
}
|
||||
setState(() => _tab = tab);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
setState(() => _refreshing = true);
|
||||
@@ -106,31 +106,10 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
|
||||
void _showComingSoon() {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部频道 tab:漫剧点击提示即将上线。
|
||||
enum _FeedTab {
|
||||
anime,
|
||||
real,
|
||||
recommend;
|
||||
|
||||
bool get comingSoon => this == _FeedTab.anime;
|
||||
|
||||
String get keyName => switch (this) {
|
||||
_FeedTab.anime => 'anime',
|
||||
_FeedTab.real => 'real',
|
||||
_FeedTab.recommend => 'recommend',
|
||||
};
|
||||
|
||||
String label(AppLocalizations l10n) => switch (this) {
|
||||
_FeedTab.anime => l10n.feedTabAnime,
|
||||
_FeedTab.real => l10n.feedTabReal,
|
||||
_FeedTab.recommend => l10n.feedTabForYou,
|
||||
String _label(HomeFeedChannel channel) => switch (channel) {
|
||||
HomeFeedChannel.anime => widget.l10n.feedTabAnime,
|
||||
HomeFeedChannel.live => widget.l10n.feedTabReal,
|
||||
HomeFeedChannel.recommend => widget.l10n.feedTabForYou,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,7 +202,7 @@ class _RefreshButton extends StatelessWidget {
|
||||
|
||||
class _FeedTabItem extends StatelessWidget {
|
||||
const _FeedTabItem({
|
||||
required this.tab,
|
||||
required this.channel,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.fontSize,
|
||||
@@ -232,7 +211,7 @@ class _FeedTabItem extends StatelessWidget {
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _FeedTab tab;
|
||||
final HomeFeedChannel channel;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final double fontSize;
|
||||
@@ -243,7 +222,7 @@ class _FeedTabItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: Key('home-feed-tab-${tab.keyName}'),
|
||||
key: Key('home-feed-tab-${_keyName(channel)}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
@@ -272,4 +251,10 @@ class _FeedTabItem extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _keyName(HomeFeedChannel channel) => switch (channel) {
|
||||
HomeFeedChannel.anime => 'anime',
|
||||
HomeFeedChannel.live => 'real',
|
||||
HomeFeedChannel.recommend => 'recommend',
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -38,6 +42,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
required this.drama,
|
||||
required this.episode,
|
||||
required this.controller,
|
||||
required this.locked,
|
||||
required this.liked,
|
||||
required this.saved,
|
||||
required this.visible,
|
||||
@@ -58,12 +63,15 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
this.authorNote = '',
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.thumbnailService,
|
||||
this.spriteAxisService,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final Episode episode;
|
||||
final ValueListenable<VideoPlayerValue>? controller;
|
||||
final bool locked;
|
||||
final bool liked;
|
||||
final int? likeCount;
|
||||
final int? favoriteCount;
|
||||
@@ -84,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();
|
||||
@@ -285,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -340,6 +354,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
child: SizedBox(
|
||||
height: _fullBarHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
@@ -387,10 +402,16 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
bottom: 0,
|
||||
child: _FeedProgressBar(
|
||||
controller: widget.controller,
|
||||
locked: widget.locked,
|
||||
height: _progressHeight,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -6,22 +6,35 @@ const double _thumbSize = 6;
|
||||
const double _thumbActiveSize = 14;
|
||||
const double _trackActiveHeight = 6;
|
||||
const double _lineCenterFromBottom = 8;
|
||||
const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
|
||||
|
||||
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
|
||||
class _FeedProgressBar extends StatefulWidget {
|
||||
const _FeedProgressBar({
|
||||
required this.controller,
|
||||
required this.locked,
|
||||
required this.height,
|
||||
required this.hitHeight,
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.thumbnailService,
|
||||
this.spriteAxisService,
|
||||
this.thumbnailsUrl,
|
||||
required this.episodeId,
|
||||
required this.posterUrl,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? controller;
|
||||
final bool locked;
|
||||
final double height;
|
||||
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();
|
||||
@@ -48,14 +61,27 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
if (widget.locked) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_preview = null;
|
||||
} else {
|
||||
_syncBufferState(notify: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detachSource(widget.controller);
|
||||
_bufferTimer?.cancel();
|
||||
if (_preview != null) widget.thumbnailService?.cancelPending();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -66,15 +92,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
final width = constraints.maxWidth;
|
||||
final source = widget.controller;
|
||||
final scrubbing = _preview != null;
|
||||
final loading =
|
||||
source == null ||
|
||||
(!scrubbing && (!source.value.isInitialized || _buffering));
|
||||
final track = source == null
|
||||
? _loadingTrack()
|
||||
final loading = !widget.locked && !scrubbing && _buffering;
|
||||
final track = widget.locked
|
||||
? _inactiveTrack()
|
||||
: source == null
|
||||
? _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;
|
||||
@@ -86,23 +113,55 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
return _track(_preview ?? played, width);
|
||||
},
|
||||
);
|
||||
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,
|
||||
onHorizontalDragStart: loading
|
||||
? null
|
||||
: (details) {
|
||||
onHorizontalDragStart: interactive
|
||||
? (details) {
|
||||
widget.onScrubStart?.call();
|
||||
_update(details.localPosition.dx / width);
|
||||
},
|
||||
onHorizontalDragUpdate: loading
|
||||
? null
|
||||
: (details) => _update(details.localPosition.dx / width),
|
||||
onHorizontalDragEnd: loading ? null : (_) => _commit(_preview),
|
||||
onHorizontalDragCancel: loading
|
||||
? null
|
||||
: () => setState(() => _preview = null),
|
||||
child: track,
|
||||
}
|
||||
: null,
|
||||
onHorizontalDragUpdate: interactive
|
||||
? (details) => _update(details.localPosition.dx / width)
|
||||
: null,
|
||||
onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -121,7 +180,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
|
||||
void _syncBufferState({bool notify = true}) {
|
||||
final source = widget.controller;
|
||||
if (source == null) {
|
||||
if (widget.locked || source == null) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
@@ -131,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) {
|
||||
@@ -180,18 +239,44 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inactiveTrack() {
|
||||
final bottom = _lineCenterFromBottom - widget.height / 2;
|
||||
return SizedBox(
|
||||
height: widget.hitHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: bottom,
|
||||
child: SizedBox(
|
||||
height: widget.height,
|
||||
child: ColoredBox(key: _inactiveTrackKey, color: Colors.white24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _update(double fraction) {
|
||||
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
|
||||
}
|
||||
|
||||
void _commit(double? fraction) {
|
||||
if (fraction == null) return;
|
||||
if (widget.locked || fraction == null) return;
|
||||
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
|
||||
if (total > 0) {
|
||||
widget.onSeek?.call(
|
||||
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
||||
);
|
||||
}
|
||||
widget.thumbnailService?.cancelPending();
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
void _cancelPreview() {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
/// 播放未就绪时沿进度轨道循环移动的加载高光。
|
||||
class LoadingProgressTrack extends StatefulWidget {
|
||||
const LoadingProgressTrack({
|
||||
super.key = const Key('progress-loading'),
|
||||
required this.height,
|
||||
this.trackColor = Colors.white24,
|
||||
this.highlightColor = AppColors.blue,
|
||||
this.highlightColor = Colors.white,
|
||||
});
|
||||
|
||||
final double height;
|
||||
|
||||
@@ -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';
|
||||
@@ -263,6 +264,12 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
semanticsLabel: data.l10n.seekVideo,
|
||||
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';
|
||||
|
||||
/// 播放浮层展示数据。
|
||||
@@ -23,6 +25,9 @@ class PlayerOverlayData {
|
||||
required this.playbackSpeed,
|
||||
required this.totalEpisodes,
|
||||
required this.boosting,
|
||||
required this.locked,
|
||||
this.thumbnailService,
|
||||
this.spriteAxisService,
|
||||
this.authorNote = '',
|
||||
});
|
||||
|
||||
@@ -65,6 +70,13 @@ class PlayerOverlayData {
|
||||
/// 是否处于长按加速态。
|
||||
final bool boosting;
|
||||
|
||||
/// 当前剧集是否锁定。
|
||||
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';
|
||||
}
|
||||
@@ -6,8 +6,13 @@ 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');
|
||||
|
||||
/// Keeps seeks within the playable range so dragging to the edge does not
|
||||
/// trigger the player's completion callback before playback resumes.
|
||||
@@ -29,6 +34,12 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.showTimes = true,
|
||||
this.compact = false,
|
||||
this.inactiveTrackColor = AppColors.border,
|
||||
this.locked = false,
|
||||
this.thumbnailService,
|
||||
this.spriteAxisService,
|
||||
this.episodeId,
|
||||
this.posterUrl = '',
|
||||
this.thumbnailsUrl,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -38,6 +49,12 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final bool showTimes;
|
||||
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();
|
||||
@@ -65,14 +82,29 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
if (widget.locked) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_previewFraction.value = null;
|
||||
} else {
|
||||
_syncBufferState(notify: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detachSource(widget.valueListenable);
|
||||
_bufferTimer?.cancel();
|
||||
if (_previewFraction.value != null) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
}
|
||||
_previewFraction.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -90,7 +122,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
|
||||
void _syncBufferState({bool notify = true}) {
|
||||
final source = widget.valueListenable;
|
||||
if (source == null) {
|
||||
if (widget.locked || source == null) {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_setBuffering(false, notify: notify);
|
||||
@@ -100,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) {
|
||||
@@ -133,15 +165,11 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
}
|
||||
|
||||
Future<void> _finish(double fraction, Duration duration) async {
|
||||
final source = widget.valueListenable;
|
||||
if (widget.locked) return;
|
||||
_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
|
||||
@@ -166,8 +194,12 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
Widget _buildTrack(BuildContext context, VideoPlayerValue video) {
|
||||
final total = video.duration.inMilliseconds;
|
||||
final scrubbing = _previewFraction.value != null;
|
||||
final loading = !scrubbing && (!video.isInitialized || _buffering);
|
||||
final enabled = video.isInitialized && total > 0 && widget.onSeek != null;
|
||||
final loading = !widget.locked && !scrubbing && _buffering;
|
||||
final enabled =
|
||||
!widget.locked &&
|
||||
video.isInitialized &&
|
||||
total > 0 &&
|
||||
widget.onSeek != null;
|
||||
final played = total > 0
|
||||
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
@@ -175,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);
|
||||
@@ -184,70 +221,113 @@ 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: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inactiveTrack(double height) {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
child: ColoredBox(
|
||||
key: _inactiveTrackKey,
|
||||
color: widget.inactiveTrackColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_record.dart';
|
||||
import '../../core/data/repositories/catalog_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
@@ -17,15 +17,17 @@ 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 '../drama_detail/drama_detail_view.dart';
|
||||
import '../player/open_player.dart';
|
||||
import 'watch_records_binding.dart';
|
||||
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.drama!),
|
||||
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,
|
||||
@@ -191,9 +218,22 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(Drama drama) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
||||
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,
|
||||
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),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
@@ -76,6 +78,7 @@ void main() {
|
||||
|
||||
expect(find.text('Enter your email'), findsOneWidget);
|
||||
expect(find.text('Welcome back'), findsOneWidget);
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -92,6 +95,81 @@ void main() {
|
||||
find.text('Password needs both letters and numbers'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_show_centered_toast_when_login_api_returns_an_error', (
|
||||
tester,
|
||||
) async {
|
||||
final api = _FailingAuthApi(loginMessage: 'Invalid email or password');
|
||||
await pumpAuthPage(tester, api: api);
|
||||
await _enterValidCredentials(tester);
|
||||
|
||||
await tester.ensureVisible(find.byKey(const Key('auth-submit')));
|
||||
await tester.tap(find.byKey(const Key('auth-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final toast = find.byKey(const Key('centered-toast'));
|
||||
expect(toast, findsOneWidget);
|
||||
expect(find.text('Invalid email or password'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(api.loginCalls, 1);
|
||||
expect(
|
||||
tester.getCenter(toast),
|
||||
offsetMoreOrLessEquals(
|
||||
tester.getCenter(find.byType(Scaffold)),
|
||||
epsilon: 1,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(find.byKey(const Key('auth-submit')))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
await _pumpToastAway(tester);
|
||||
expect(toast, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_show_centered_toast_when_register_api_returns_an_error', (
|
||||
tester,
|
||||
) async {
|
||||
final api = _FailingAuthApi(registerMessage: 'Email already exists');
|
||||
await pumpAuthPage(tester, signUp: true, api: api);
|
||||
await _enterValidCredentials(tester);
|
||||
|
||||
await tester.ensureVisible(find.byKey(const Key('auth-submit')));
|
||||
await tester.tap(find.byKey(const Key('auth-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
|
||||
expect(find.text('Email already exists'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(api.registerCalls, 1);
|
||||
|
||||
await _pumpToastAway(tester);
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_show_network_error_when_login_throws_an_unknown_exception',
|
||||
(tester) async {
|
||||
final api = _FailingAuthApi(loginThrowsUnknown: true);
|
||||
await pumpAuthPage(tester, api: api);
|
||||
await _enterValidCredentials(tester);
|
||||
|
||||
await tester.ensureVisible(find.byKey(const Key('auth-submit')));
|
||||
await tester.tap(find.byKey(const Key('auth-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
|
||||
expect(find.text('Network error, please try again'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
|
||||
await _pumpToastAway(tester);
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -263,6 +341,144 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_keep_password_above_ios_keyboard', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
|
||||
await pumpAuthPage(tester);
|
||||
await tester.tap(find.byKey(const Key('auth-password')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
_expectAboveKeyboard(tester, const Key('auth-password'), 300);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('should_keep_signup_password_above_ios_keyboard', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
|
||||
await pumpAuthPage(tester, signUp: true);
|
||||
await tester.tap(find.byKey(const Key('auth-password')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
_expectAboveKeyboard(tester, const Key('auth-password'), 300);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('should_keep_password_above_android_channel_keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const keyboardChannel = MethodChannel('com.stormg.lumadrama/keyboard');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
keyboardChannel,
|
||||
(call) async => call.method == 'get' ? 300 : null,
|
||||
);
|
||||
addTearDown(() {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
keyboardChannel,
|
||||
null,
|
||||
);
|
||||
});
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewInsets = const FakeViewPadding();
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
|
||||
await pumpAuthPage(tester);
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
keyboardChannel.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
const MethodCall('insetsChanged', 300),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('auth-password')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
_expectAboveKeyboard(tester, const Key('auth-password'), 300);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _enterValidCredentials(WidgetTester tester) async {
|
||||
await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com');
|
||||
await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234');
|
||||
}
|
||||
|
||||
Future<void> _pumpToastAway(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) {
|
||||
final screenHeight =
|
||||
tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(fieldKey)).dy,
|
||||
lessThanOrEqualTo(screenHeight - inset),
|
||||
);
|
||||
}
|
||||
|
||||
class _FailingAuthApi extends AuthApi {
|
||||
_FailingAuthApi({
|
||||
this.loginMessage = 'login failed',
|
||||
this.registerMessage = 'register failed',
|
||||
this.loginThrowsUnknown = false,
|
||||
}) : super(
|
||||
ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
|
||||
),
|
||||
);
|
||||
|
||||
final String loginMessage;
|
||||
final String registerMessage;
|
||||
final bool loginThrowsUnknown;
|
||||
int loginCalls = 0;
|
||||
int registerCalls = 0;
|
||||
|
||||
@override
|
||||
Future<AuthSession> login({
|
||||
required String email,
|
||||
required String password,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
loginCalls++;
|
||||
if (loginThrowsUnknown) throw StateError('unexpected');
|
||||
throw ApiException(401, loginMessage);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthSession> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
registerCalls++;
|
||||
throw ApiException(409, registerMessage);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingAuthApi extends AuthApi {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/auth_session.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
@@ -96,7 +98,147 @@ void main() {
|
||||
|
||||
expect(find.text('Enter a valid phone number'), findsOneWidget);
|
||||
expect(adapter.requests, isEmpty);
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_show_centered_toast_when_otp_request_fails', (
|
||||
tester,
|
||||
) async {
|
||||
final api = _FailingPhoneAuthApi(sendOtpMessage: 'Too many OTP requests');
|
||||
final auth = AuthController(api, AuthSessionStore());
|
||||
await _pumpHost(tester, auth);
|
||||
await tester.tap(find.byKey(const Key('open-phone-sheet')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('auth-phone-number')),
|
||||
'08123456789',
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('auth-send-code')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
|
||||
expect(find.text('Too many OTP requests'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(api.sendOtpCalls, 1);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextButton>(find.byKey(const Key('auth-send-code')))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
await _pumpToastAway(tester);
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_show_centered_toast_when_phone_login_fails', (
|
||||
tester,
|
||||
) async {
|
||||
final api = _FailingPhoneAuthApi(loginMessage: 'Invalid verification code');
|
||||
final auth = AuthController(api, AuthSessionStore());
|
||||
await _pumpHost(tester, auth);
|
||||
await tester.tap(find.byKey(const Key('open-phone-sheet')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('auth-phone-number')),
|
||||
'08123456789',
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('auth-phone-code')), '123456');
|
||||
|
||||
await tester.tap(find.byKey(const Key('auth-phone-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
|
||||
expect(find.text('Invalid verification code'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(api.loginWithPhoneCalls, 1);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(find.byKey(const Key('auth-phone-submit')))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
await _pumpToastAway(tester);
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_code_above_ios_keyboard', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
|
||||
),
|
||||
);
|
||||
final auth = AuthController(
|
||||
AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)),
|
||||
AuthSessionStore(),
|
||||
);
|
||||
await _pumpHost(tester, auth);
|
||||
await tester.tap(find.byKey(const Key('open-phone-sheet')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('auth-phone-code')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(const Key('auth-phone-code'))).dy,
|
||||
lessThanOrEqualTo(844 - 300),
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpToastAway(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _FailingPhoneAuthApi extends AuthApi {
|
||||
_FailingPhoneAuthApi({
|
||||
this.sendOtpMessage = 'otp failed',
|
||||
this.loginMessage = 'login failed',
|
||||
}) : super(
|
||||
ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
|
||||
),
|
||||
);
|
||||
|
||||
final String sendOtpMessage;
|
||||
final String loginMessage;
|
||||
int sendOtpCalls = 0;
|
||||
int loginWithPhoneCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> sendOtp({
|
||||
required String target,
|
||||
required String scene,
|
||||
required String deviceId,
|
||||
}) async {
|
||||
sendOtpCalls++;
|
||||
throw ApiException(429, sendOtpMessage);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthSession> loginWithPhone({
|
||||
required String target,
|
||||
required String code,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
loginWithPhoneCalls++;
|
||||
throw ApiException(401, loginMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpHost(WidgetTester tester, AuthController auth) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:async';
|
||||
|
||||
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/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||
@@ -136,6 +137,14 @@ class ScriptedCatalogRepository implements CatalogRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => feed;
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async => const FeedPage();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
detailCalls++;
|
||||
|
||||
@@ -20,12 +20,16 @@ void main() {
|
||||
'title': '第 1 集',
|
||||
'isFree': true,
|
||||
'playUrl': '/files/episode/9.mp4',
|
||||
'likeCount': '18',
|
||||
'commentCount': 4,
|
||||
},
|
||||
});
|
||||
|
||||
expect(drama.detailLoaded, isFalse);
|
||||
expect(drama.episodes, hasLength(1));
|
||||
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
|
||||
expect(drama.episodes.single.likeCount, 18);
|
||||
expect(drama.episodes.single.commentCount, 4);
|
||||
expect(drama.episodeCount, 77);
|
||||
expect(drama.isHot, isTrue);
|
||||
expect(drama.language, 'zh-CN');
|
||||
@@ -50,7 +54,14 @@ void main() {
|
||||
'cover': '/files/cover.jpg',
|
||||
'totalEpisodes': 77,
|
||||
'episodes': [
|
||||
{'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true},
|
||||
{
|
||||
'id': 9,
|
||||
'number': 1,
|
||||
'title': '第 1 集',
|
||||
'isFree': true,
|
||||
'likeCount': '21',
|
||||
'commentCount': '6',
|
||||
},
|
||||
{'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false},
|
||||
],
|
||||
});
|
||||
@@ -58,5 +69,7 @@ void main() {
|
||||
expect(drama.detailLoaded, isTrue);
|
||||
expect(drama.episodeCount, 77);
|
||||
expect(drama.episodes.first.id, '9');
|
||||
expect(drama.episodes.first.likeCount, 21);
|
||||
expect(drama.episodes.first.commentCount, 6);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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/catalog_api.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
String envelope(Object? data) =>
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
|
||||
|
||||
test('should_send_types_and_cursor_when_feed_query_has_filters', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope({
|
||||
'list': [
|
||||
{
|
||||
'id': '11',
|
||||
'title': 'Anime',
|
||||
'cover': '/files/anime.png',
|
||||
'preview': {
|
||||
'id': '12',
|
||||
'number': 1,
|
||||
'title': 'Episode 1',
|
||||
'isFree': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'nextCursor': 10,
|
||||
'hasMore': true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = CatalogApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.feed(
|
||||
types: const ['ANIME', 'LIVE'],
|
||||
categoryId: '7',
|
||||
cursor: '9',
|
||||
size: 20,
|
||||
);
|
||||
|
||||
expect(adapter.requests.single.uri.path, '/app/feed');
|
||||
expect(adapter.requests.single.queryParameters, {
|
||||
'types': 'ANIME,LIVE',
|
||||
'categoryId': '7',
|
||||
'cursor': '9',
|
||||
'size': '20',
|
||||
});
|
||||
expect(page.dramas.single.id, '11');
|
||||
expect(page.nextCursor, '10');
|
||||
expect(page.hasMore, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'should_omit_empty_types_and_treat_null_cursor_string_as_empty',
|
||||
() async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope({'list': [], 'nextCursor': 'null', 'hasMore': false}),
|
||||
),
|
||||
);
|
||||
final api = CatalogApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.feed();
|
||||
|
||||
expect(adapter.requests.single.queryParameters, {'size': '10'});
|
||||
expect(page.dramas, isEmpty);
|
||||
expect(page.nextCursor, isNull);
|
||||
expect(page.hasMore, isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -55,12 +55,18 @@ void main() {
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.page(dramaId: '5', cursor: '99', size: 20);
|
||||
final page = await api.page(
|
||||
dramaId: '5',
|
||||
episodeId: '7',
|
||||
cursor: '99',
|
||||
size: 20,
|
||||
);
|
||||
|
||||
expect(adapter.requests.single.method, 'GET');
|
||||
expect(adapter.requests.single.uri.path, '/app/comments');
|
||||
expect(adapter.requests.single.queryParameters, {
|
||||
'dramaId': '5',
|
||||
'episodeId': '7',
|
||||
'cursor': '99',
|
||||
'size': '20',
|
||||
});
|
||||
@@ -93,6 +99,7 @@ void main() {
|
||||
|
||||
final reply = await api.publish(
|
||||
dramaId: '5',
|
||||
episodeId: '7',
|
||||
content: 'great badword',
|
||||
parentId: '2111012753262120962',
|
||||
);
|
||||
@@ -101,6 +108,7 @@ void main() {
|
||||
expect(adapter.requests.single.uri.path, '/app/comments');
|
||||
expect(adapter.requests.single.data, {
|
||||
'dramaId': '5',
|
||||
'episodeId': '7',
|
||||
'content': 'great badword',
|
||||
'parentId': '2111012753262120962',
|
||||
});
|
||||
@@ -135,12 +143,15 @@ void main() {
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final count = await api.count('5');
|
||||
final count = await api.count('5', episodeId: '7');
|
||||
|
||||
expect(count, 17);
|
||||
expect(adapter.requests.single.method, 'GET');
|
||||
expect(adapter.requests.single.uri.path, '/app/comments/count');
|
||||
expect(adapter.requests.single.queryParameters, {'dramaId': '5'});
|
||||
expect(adapter.requests.single.queryParameters, {
|
||||
'dramaId': '5',
|
||||
'episodeId': '7',
|
||||
});
|
||||
});
|
||||
|
||||
test('should_parse_string_comment_count', () async {
|
||||
|
||||
@@ -12,10 +12,12 @@ void main() {
|
||||
CommentController controller(
|
||||
FakeCommentRepository comments, {
|
||||
FakeInteractionRepository? interactions,
|
||||
String? episodeId,
|
||||
}) => CommentController(
|
||||
comments: comments,
|
||||
interactions: interactions ?? FakeInteractionRepository(),
|
||||
dramaId: '5',
|
||||
episodeId: episodeId,
|
||||
);
|
||||
|
||||
test('should_append_next_page_with_cursor_when_has_more_is_true', () async {
|
||||
@@ -63,6 +65,42 @@ void main() {
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
test('should_pass_episode_id_to_every_page_and_publish_request', () async {
|
||||
final root = commentFixture(id: '10', replyCount: 1);
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [
|
||||
CommentPage(
|
||||
comments: [root],
|
||||
nextCursor: '10',
|
||||
hasMore: true,
|
||||
),
|
||||
CommentPage(comments: [commentFixture(id: '9')]),
|
||||
CommentPage(
|
||||
comments: [commentFixture(id: '11', rootId: '10')],
|
||||
nextCursor: '11',
|
||||
hasMore: true,
|
||||
),
|
||||
CommentPage(comments: [commentFixture(id: '12', rootId: '10')]),
|
||||
],
|
||||
publishResult: commentFixture(id: '13', mine: true),
|
||||
);
|
||||
final model = controller(repository, episodeId: 'episode-7');
|
||||
|
||||
await model.load();
|
||||
await model.loadMore();
|
||||
await model.loadReplies('10');
|
||||
await model.loadMoreReplies('10');
|
||||
await model.publish('Nice episode');
|
||||
|
||||
expect(
|
||||
repository.pageCalls.map((call) => call.episodeId),
|
||||
everyElement('episode-7'),
|
||||
);
|
||||
expect(repository.pageCalls, hasLength(4));
|
||||
expect(repository.publishCalls.single.episodeId, 'episode-7');
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
test('should_use_server_content_and_pending_status_when_review_is_returned', () async {
|
||||
final repository = FakeCommentRepository(
|
||||
publishResult: commentFixture(
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/comment.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
@@ -33,6 +35,50 @@ const _drama = Drama(
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('should_ignore_stale_keyboard_inset_when_composer_opens', (
|
||||
tester,
|
||||
) async {
|
||||
const keyboardChannel = MethodChannel('com.stormg.lumadrama/keyboard');
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
keyboardChannel,
|
||||
(call) async => call.method == 'get' ? 300 : null,
|
||||
);
|
||||
addTearDown(() {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
keyboardChannel,
|
||||
null,
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final composer = find.byKey(const Key('comment-composer-sheet'));
|
||||
final panel = find.byKey(const Key('comment-composer-panel'));
|
||||
final input = find.byKey(const Key('comment-composer-input'));
|
||||
expect(
|
||||
find.byKey(const Key('comment-composer-focus-scope')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.getSize(composer).height, inInclusiveRange(100, 150));
|
||||
expect(
|
||||
tester.getSize(panel).height,
|
||||
closeTo(tester.getSize(composer).height, 1),
|
||||
);
|
||||
expect(tester.getBottomLeft(panel).dy, closeTo(844, 0.1));
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isTrue);
|
||||
expect(tester.testTextInput.isVisible, isTrue);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.withData({
|
||||
@@ -42,25 +88,71 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('should_follow_live_view_insets_on_ios', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 160));
|
||||
|
||||
final animatedPadding = tester.widget<AnimatedPadding>(
|
||||
find.byKey(const Key('comment-composer-keyboard-padding')),
|
||||
);
|
||||
expect((animatedPadding.padding as EdgeInsets).bottom, 300);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(const Key('comment-composer-post'))).dy,
|
||||
lessThanOrEqualTo(844 - 300),
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('should_use_zero_live_inset_when_ios_keyboard_is_hidden', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final animatedPadding = tester.widget<AnimatedPadding>(
|
||||
find.byKey(const Key('comment-composer-keyboard-padding')),
|
||||
);
|
||||
expect((animatedPadding.padding as EdgeInsets).bottom, 0);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('should_gate_guest_comment_input_without_focus', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
final input = find.byKey(const Key('comment-input'));
|
||||
final field = tester.widget<TextField>(input);
|
||||
final placeholder = find.byKey(const Key('comment-input-placeholder'));
|
||||
|
||||
expect(field.decoration?.hintText, 'Sign in to comment');
|
||||
expect(field.focusNode?.hasFocus, isFalse);
|
||||
await tester.tap(input);
|
||||
expect(placeholder, findsOneWidget);
|
||||
expect(find.text('Sign in to comment'), findsOneWidget);
|
||||
await tester.tap(placeholder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isFalse);
|
||||
await tester.tap(find.text('Not now'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isFalse);
|
||||
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing);
|
||||
expect(placeholder, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_focus_comment_input_after_successful_login', (
|
||||
@@ -71,7 +163,7 @@ void main() {
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
final auth = await _pumpSheet(tester, repository: repository);
|
||||
|
||||
await tester.tap(find.byKey(const Key('comment-input')));
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('Sign In'));
|
||||
@@ -80,18 +172,52 @@ void main() {
|
||||
(auth as _TestAuthController).setSignedIn(true);
|
||||
await tester.pump();
|
||||
Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(const Key('comment-input')))
|
||||
.widget<TextField>(find.byKey(const Key('comment-composer-input')))
|
||||
.focusNode
|
||||
?.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_open_composer_sheet_when_fake_send_is_tapped', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final repository = FakeCommentRepository(pages: [const CommentPage()]);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final composer = find.byKey(const Key('comment-composer-sheet'));
|
||||
expect(composer, findsOneWidget);
|
||||
final compactHeight = tester.getSize(composer).height;
|
||||
expect(compactHeight, inInclusiveRange(100, 150));
|
||||
final input = find.byKey(const Key('comment-composer-input'));
|
||||
expect(input, findsOneWidget);
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isTrue);
|
||||
expect(
|
||||
tester.widget<TextField>(input).textAlignVertical,
|
||||
TextAlignVertical.center,
|
||||
);
|
||||
|
||||
await tester.enterText(input, 'First line\nSecond line');
|
||||
await tester.pump();
|
||||
expect(tester.getSize(composer).height, greaterThan(compactHeight));
|
||||
expect(tester.getSize(composer).height, lessThan(844 / 2));
|
||||
final multilineField = tester.widget<TextField>(input);
|
||||
expect(multilineField.textAlignVertical, TextAlignVertical.top);
|
||||
expect(multilineField.style?.height, 1.4);
|
||||
});
|
||||
|
||||
testWidgets('should_gate_guest_post_without_creating_comment', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -160,6 +286,36 @@ void main() {
|
||||
expect(repository.countCalls, ['5']);
|
||||
});
|
||||
|
||||
testWidgets('should_scope_list_count_and_publish_to_episode', (tester) async {
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [const CommentPage()],
|
||||
countResult: 3,
|
||||
publishResult: commentFixture(
|
||||
id: '11',
|
||||
content: 'Episode comment',
|
||||
mine: true,
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository, episodeId: 'episode-7');
|
||||
|
||||
expect(repository.pageCalls.single.episodeId, 'episode-7');
|
||||
expect(repository.countEpisodeCalls, ['episode-7']);
|
||||
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('comment-composer-input')),
|
||||
'Episode comment',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('comment-composer-post')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.publishCalls.single.episodeId, 'episode-7');
|
||||
expect(repository.countEpisodeCalls, ['episode-7', 'episode-7']);
|
||||
});
|
||||
|
||||
testWidgets('should_fall_back_to_exact_count_when_server_total_fails', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -201,64 +357,339 @@ void main() {
|
||||
publishError: const ApiException(400, '评论包含敏感词'),
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await _pumpSheet(
|
||||
tester,
|
||||
repository: repository,
|
||||
locale: const Locale('zh'),
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('comment-input')),
|
||||
find.byKey(const Key('comment-composer-input')),
|
||||
'blocked text',
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('comment-composer-post')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('评论包含敏感词'), findsOneWidget);
|
||||
final field = tester.widget<TextField>(
|
||||
find.byKey(const Key('comment-input')),
|
||||
find.byKey(const Key('comment-composer-input')),
|
||||
);
|
||||
expect(field.controller?.text, 'blocked text');
|
||||
expect(find.text('发表成功'), findsNothing);
|
||||
// 发送失败时保留输入与焦点,用户可以直接改完再发。
|
||||
expect(field.focusNode?.hasFocus, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_clear_and_keep_input_focused_after_successful_post', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('should_close_composer_after_successful_post', (tester) async {
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [const CommentPage()],
|
||||
publishResult: commentFixture(id: '11', content: 'Nice demo', mine: true),
|
||||
countResult: 1,
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
final input = find.byKey(const Key('comment-input'));
|
||||
await tester.tap(input);
|
||||
await tester.pump();
|
||||
await _pumpSheet(
|
||||
tester,
|
||||
repository: repository,
|
||||
locale: const Locale('zh'),
|
||||
);
|
||||
final input = find.byKey(const Key('comment-composer-input'));
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isTrue);
|
||||
await tester.enterText(input, 'Nice demo');
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<TextButton>(find.byKey(const Key('comment-post')))
|
||||
.widget<TextButton>(find.byKey(const Key('comment-composer-post')))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('comment-composer-post')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(tester.widget<TextField>(input).controller?.text, isEmpty);
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isTrue);
|
||||
expect(tester.widget<TextField>(input).maxLines, 4);
|
||||
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing);
|
||||
final successOverlay = find.byKey(
|
||||
const Key('comment-post-success-snackbar'),
|
||||
);
|
||||
expect(successOverlay, findsOneWidget);
|
||||
expect(find.text('发表成功'), findsOneWidget);
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
final capsule = tester.widget<Container>(successOverlay);
|
||||
final decoration = capsule.decoration! as BoxDecoration;
|
||||
expect(decoration.color, Colors.black.withValues(alpha: 0.82));
|
||||
expect(decoration.borderRadius, BorderRadius.circular(8));
|
||||
expect(
|
||||
capsule.padding,
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
);
|
||||
final successText = tester.widget<Text>(find.text('发表成功'));
|
||||
expect(successText.style?.color, Colors.white);
|
||||
expect(successText.style?.fontSize, 14);
|
||||
final placeholderRect = tester.getRect(
|
||||
find.byKey(const Key('comment-input-placeholder')),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(successOverlay).bottom,
|
||||
lessThan(placeholderRect.top),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 1200));
|
||||
expect(successOverlay, findsNothing);
|
||||
expect(find.byKey(const Key('comment-list')), findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-count-label')), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextButton>(find.byKey(const Key('comment-post')))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget);
|
||||
expect(repository.countCalls, ['5', '5']);
|
||||
});
|
||||
|
||||
testWidgets('should_expand_composer_only_while_input_is_focused', (
|
||||
testWidgets('should_keep_comment_panel_fixed_and_open_composer_sheet', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [
|
||||
CommentPage(
|
||||
comments: [commentFixture(id: '10', content: 'A comment')],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
final commentsSheet = find.byKey(const Key('player-comments-sheet'));
|
||||
final list = find.byKey(const Key('comment-list'));
|
||||
final placeholder = find.byKey(const Key('comment-input-placeholder'));
|
||||
final sheetRect = tester.getRect(commentsSheet);
|
||||
final sheetHeight = tester.getSize(commentsSheet).height;
|
||||
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final composer = find.byKey(const Key('comment-composer-sheet'));
|
||||
final composerPanel = find.byKey(const Key('comment-composer-panel'));
|
||||
final composerInput = find.byKey(const Key('comment-composer-input'));
|
||||
expect(composer, findsOneWidget);
|
||||
expect(composerPanel, findsOneWidget);
|
||||
final overlayRoot = find.byKey(const Key('comment-composer-overlay'));
|
||||
expect(overlayRoot, findsOneWidget);
|
||||
final overlayStack = tester.widget<Stack>(overlayRoot);
|
||||
expect(overlayStack.children, hasLength(2));
|
||||
final dismissLayer = overlayStack.children.first as Positioned;
|
||||
expect(dismissLayer.left, 0);
|
||||
expect(dismissLayer.top, 0);
|
||||
expect(dismissLayer.right, 0);
|
||||
expect(dismissLayer.bottom, 0);
|
||||
final dismissGesture = dismissLayer.child as GestureDetector;
|
||||
expect(dismissGesture.child, isNull);
|
||||
expect(dismissGesture.behavior, HitTestBehavior.opaque);
|
||||
final compactHeight = tester.getSize(composer).height;
|
||||
expect(compactHeight, inInclusiveRange(100, 150));
|
||||
expect(tester.widget<TextField>(composerInput).focusNode?.hasFocus, isTrue);
|
||||
final underlyingSheet = find.byKey(
|
||||
const Key('player-comments-sheet'),
|
||||
skipOffstage: false,
|
||||
);
|
||||
expect(tester.getRect(underlyingSheet), sheetRect);
|
||||
expect(tester.getSize(underlyingSheet).height, closeTo(sheetHeight, 0.1));
|
||||
|
||||
final field = tester.widget<TextField>(composerInput);
|
||||
expect(field.minLines, 1);
|
||||
expect(field.maxLines, 4);
|
||||
expect(field.style?.fontSize, 15);
|
||||
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
||||
expect(find.byIcon(Icons.emoji_emotions_outlined), findsNothing);
|
||||
expect(find.byIcon(Icons.alternate_email_rounded), findsNothing);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final keyboardUpInputTop = tester.getTopLeft(composer).dy;
|
||||
final keyboardUpBottom = tester.getBottomLeft(composer).dy;
|
||||
final keyboardUpPanelRect = tester.getRect(composerPanel);
|
||||
expect(tester.getSize(composer).height, closeTo(compactHeight, 0.1));
|
||||
expect(keyboardUpBottom, closeTo(844 - 180, 0.1));
|
||||
expect(keyboardUpPanelRect.bottom, closeTo(844, 0.1));
|
||||
expect(
|
||||
tester.getBottomLeft(composerInput).dy,
|
||||
lessThanOrEqualTo(844 - 180),
|
||||
);
|
||||
expect(tester.getRect(underlyingSheet), sheetRect);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding();
|
||||
await tester.pumpAndSettle();
|
||||
expect(composer, findsOneWidget);
|
||||
expect(tester.getTopLeft(composer).dy, closeTo(keyboardUpInputTop, 0.1));
|
||||
expect(tester.getBottomLeft(composer).dy, closeTo(keyboardUpBottom, 0.1));
|
||||
expect(tester.getRect(composerPanel), keyboardUpPanelRect);
|
||||
expect(tester.widget<Material>(composerPanel).color, AppColors.card);
|
||||
expect(tester.getBottomLeft(composerPanel).dy, closeTo(844, 0.1));
|
||||
expect(commentsSheet, findsOneWidget);
|
||||
expect(list, findsOneWidget);
|
||||
expect(placeholder, findsOneWidget);
|
||||
expect(tester.getRect(commentsSheet), sheetRect);
|
||||
expect(tester.getSize(commentsSheet).height, closeTo(sheetHeight, 0.1));
|
||||
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
expect(composer, findsNothing);
|
||||
|
||||
await tester.tap(placeholder);
|
||||
await tester.pumpAndSettle();
|
||||
expect(composer, findsOneWidget);
|
||||
expect(tester.getBottomLeft(composer).dy, closeTo(844, 0.1));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_keep_composer_input_position_when_safe_area_padding_changes',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.padding = const FakeViewPadding();
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [
|
||||
CommentPage(
|
||||
comments: [commentFixture(id: '10', content: 'A comment')],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final input = find.byKey(const Key('comment-composer-input'));
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final keyboardUpInputRect = tester.getRect(input);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding();
|
||||
tester.view.padding = const FakeViewPadding(bottom: 48);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final keyboardClosedInputRect = tester.getRect(input);
|
||||
expect(
|
||||
keyboardClosedInputRect.top,
|
||||
closeTo(keyboardUpInputRect.top, 0.1),
|
||||
);
|
||||
expect(
|
||||
keyboardClosedInputRect.bottom,
|
||||
closeTo(keyboardUpInputRect.bottom, 0.1),
|
||||
);
|
||||
expect(
|
||||
keyboardClosedInputRect.height,
|
||||
closeTo(keyboardUpInputRect.height, 0.1),
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'should_keep_composer_position_when_keyboard_insets_change_after_settling',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 2200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [
|
||||
CommentPage(
|
||||
comments: [commentFixture(id: '10', content: 'A comment')],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final composer = find.byKey(const Key('comment-composer-sheet'));
|
||||
expect(composer, findsOneWidget);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 1100);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final settledTop = tester.getTopLeft(composer).dy;
|
||||
final settledBottom = tester.getBottomLeft(composer).dy;
|
||||
final settledHeight = tester.getSize(composer).height;
|
||||
expect(settledBottom, closeTo(2200 - 1100, 0.1));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 1150);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1));
|
||||
expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1));
|
||||
expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 600);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1));
|
||||
expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1));
|
||||
expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding();
|
||||
await tester.pumpAndSettle();
|
||||
expect(composer, findsOneWidget);
|
||||
expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1));
|
||||
expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1));
|
||||
expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_keep_composer_open_after_input_loses_focus', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [
|
||||
CommentPage(
|
||||
comments: [commentFixture(id: '10', content: 'A comment')],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final composer = find.byKey(const Key('comment-composer-sheet'));
|
||||
final input = find.byKey(const Key('comment-composer-input'));
|
||||
expect(composer, findsOneWidget);
|
||||
|
||||
tester.widget<TextField>(input).focusNode?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(composer, findsOneWidget);
|
||||
|
||||
await tester.tap(input);
|
||||
await tester.pump();
|
||||
expect(composer, findsOneWidget);
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('should_close_composer_before_comment_panel_from_outside', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
@@ -275,117 +706,74 @@ void main() {
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
expect(find.byKey(const Key('comment-list')), findsOneWidget);
|
||||
final editableBefore = tester.state<EditableTextState>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('comment-input')),
|
||||
matching: find.byType(EditableText),
|
||||
),
|
||||
final sheet = find.byKey(const Key('player-comments-sheet'));
|
||||
expect(
|
||||
find.byKey(const Key('player-comments-dismiss-layer')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('comment-input')));
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('comment-list')), findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-count-label')), findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-composer')), findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-input-surface')), findsOneWidget);
|
||||
final editableAfter = tester.state<EditableTextState>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('comment-input')),
|
||||
matching: find.byType(EditableText),
|
||||
),
|
||||
);
|
||||
expect(identical(editableBefore, editableAfter), isTrue);
|
||||
final field = tester.widget<TextField>(
|
||||
find.byKey(const Key('comment-input')),
|
||||
);
|
||||
expect(field.minLines, 1);
|
||||
expect(field.maxLines, 4);
|
||||
expect(field.style?.fontSize, 15);
|
||||
expect(sheet, findsOneWidget);
|
||||
final composer = find.byKey(const Key('comment-composer-sheet'));
|
||||
expect(composer, findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextButton>(find.byKey(const Key('comment-post')))
|
||||
.onPressed,
|
||||
isNull,
|
||||
find.byKey(const Key('comment-composer-dismiss-layer')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.getSize(find.byKey(const Key('comment-post'))).height, 32);
|
||||
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
||||
expect(find.byIcon(Icons.emoji_emotions_outlined), findsNothing);
|
||||
expect(find.byIcon(Icons.alternate_email_rounded), findsNothing);
|
||||
|
||||
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice');
|
||||
final sheetRect = tester.getRect(sheet);
|
||||
final composerRect = tester.getRect(composer);
|
||||
await tester.tap(find.byKey(const Key('comment-composer-input')));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<TextButton>(find.byKey(const Key('comment-post')))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
expect(composer, findsOneWidget);
|
||||
|
||||
await tester.tapAt(
|
||||
Offset(sheetRect.center.dx, (sheetRect.top + composerRect.top) / 2),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheet, findsOneWidget);
|
||||
expect(composer, findsNothing);
|
||||
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheet, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_hide_keyboard_on_back_without_closing_composer_overlay', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [
|
||||
CommentPage(
|
||||
comments: [commentFixture(id: '10', content: 'A comment')],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final sheet = find.byKey(const Key('player-comments-sheet'));
|
||||
final list = find.byKey(const Key('comment-list'));
|
||||
final sheetTop = tester.getTopLeft(sheet).dy;
|
||||
final listTop = tester.getTopLeft(list).dy;
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final composer = find.byKey(const Key('comment-composer-sheet'));
|
||||
expect(sheet, findsOneWidget);
|
||||
expect(list, findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-count-label')), findsOneWidget);
|
||||
final editableDuringKeyboard = tester.state<EditableTextState>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('comment-input')),
|
||||
matching: find.byType(EditableText),
|
||||
),
|
||||
);
|
||||
expect(identical(editableAfter, editableDuringKeyboard), isTrue);
|
||||
expect(tester.getTopLeft(sheet).dy, closeTo(sheetTop, 0.1));
|
||||
expect(tester.getTopLeft(list).dy, closeTo(listTop, 0.1));
|
||||
expect(
|
||||
tester.widget<ListView>(list).padding?.resolve(TextDirection.ltr).bottom,
|
||||
greaterThanOrEqualTo(180 + 44),
|
||||
);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(const Key('comment-input'))).dy,
|
||||
lessThanOrEqualTo(844 - 180),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(const Key('comment-input')))
|
||||
.focusNode
|
||||
?.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(composer, findsOneWidget);
|
||||
final input = find.byKey(const Key('comment-composer-input'));
|
||||
await tester.tap(input);
|
||||
await tester.pump();
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isTrue);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(list, findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-count-label')), findsOneWidget);
|
||||
expect(find.byKey(const Key('comment-composer')), findsOneWidget);
|
||||
final editableAfterKeyboard = tester.state<EditableTextState>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('comment-input')),
|
||||
matching: find.byType(EditableText),
|
||||
),
|
||||
);
|
||||
expect(identical(editableAfter, editableAfterKeyboard), isTrue);
|
||||
expect(tester.getTopLeft(sheet).dy, closeTo(sheetTop, 0.1));
|
||||
expect(tester.getTopLeft(list).dy, closeTo(listTop, 0.1));
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(const Key('comment-input')))
|
||||
.controller
|
||||
?.text,
|
||||
'Nice',
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(const Key('comment-input')))
|
||||
.focusNode
|
||||
?.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(sheet, findsOneWidget);
|
||||
expect(composer, findsOneWidget);
|
||||
expect(tester.widget<TextField>(input).focusNode?.hasFocus, isFalse);
|
||||
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheet, findsOneWidget);
|
||||
expect(composer, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_use_polished_comment_sizes', (tester) async {
|
||||
@@ -403,7 +791,21 @@ void main() {
|
||||
tester.getSize(find.byKey(const Key('comment-post'))),
|
||||
const Size(38, 38),
|
||||
);
|
||||
expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('comment-input-placeholder'))).height,
|
||||
44,
|
||||
);
|
||||
final placeholderLabel = find.byKey(
|
||||
const Key('comment-input-placeholder-label'),
|
||||
);
|
||||
final placeholderSendIcon = find.descendant(
|
||||
of: find.byKey(const Key('comment-post')),
|
||||
matching: find.byIcon(Icons.send_rounded),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(placeholderLabel).dy,
|
||||
closeTo(tester.getCenter(placeholderSendIcon).dy, 1),
|
||||
);
|
||||
expect(find.byKey(const Key('player-comments-drag-handle')), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-comments-close'))),
|
||||
@@ -439,7 +841,7 @@ void main() {
|
||||
expect(body.style?.fontSize, 16);
|
||||
expect(body.style?.height, 1.4);
|
||||
final composer = tester.widget<Container>(
|
||||
find.byKey(const Key('comment-composer')),
|
||||
find.byKey(const Key('comment-input-placeholder')),
|
||||
);
|
||||
final decoration = composer.decoration! as BoxDecoration;
|
||||
expect(decoration.color, AppColors.commentInput);
|
||||
@@ -472,7 +874,7 @@ void main() {
|
||||
expect(find.byIcon(Icons.more_horiz_rounded), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_size_short_list_to_content', (tester) async {
|
||||
testWidgets('should_use_half_screen_height_for_one_comment', (tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -480,10 +882,7 @@ void main() {
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [
|
||||
CommentPage(
|
||||
comments: [
|
||||
commentFixture(id: '10', content: 'Short list one'),
|
||||
commentFixture(id: '11', content: 'Short list two'),
|
||||
],
|
||||
comments: [commentFixture(id: '10', content: 'One comment')],
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -491,14 +890,7 @@ void main() {
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
|
||||
final sheet = find.byKey(const Key('player-comments-sheet'));
|
||||
expect(tester.getSize(sheet).height, lessThan(520));
|
||||
final lastCommentBottom = tester
|
||||
.getBottomLeft(find.byKey(const Key('comment-item-11')))
|
||||
.dy;
|
||||
final composerTop = tester
|
||||
.getTopLeft(find.byKey(const Key('comment-composer')))
|
||||
.dy;
|
||||
expect(composerTop - lastCommentBottom, lessThan(80));
|
||||
expect(tester.getSize(sheet).height, closeTo(844 / 2, 0.1));
|
||||
});
|
||||
|
||||
testWidgets('should_keep_minimum_height_for_empty_state', (tester) async {
|
||||
@@ -518,7 +910,7 @@ void main() {
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final repository = FakeCommentRepository(
|
||||
pages: [CommentPage(comments: _comments(30))],
|
||||
pages: [CommentPage(comments: _comments(20))],
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
@@ -540,7 +932,7 @@ void main() {
|
||||
expect(position.pixels, greaterThan(0));
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-comments-sheet'))).height,
|
||||
520,
|
||||
closeTo(844 / 2, 0.1),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -592,22 +984,21 @@ void main() {
|
||||
final haptics = FakeHaptics();
|
||||
|
||||
await _pumpSheet(tester, repository: repository, haptics: haptics);
|
||||
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('comment-input')),
|
||||
find.byKey(const Key('comment-composer-input')),
|
||||
'hello badword',
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('comment-composer-post')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing);
|
||||
expect(find.text('hello ***'), findsOneWidget);
|
||||
expect(find.text('Under review'), findsOneWidget);
|
||||
expect(haptics.lightImpacts, 1);
|
||||
final field = tester.widget<TextField>(
|
||||
find.byKey(const Key('comment-input')),
|
||||
);
|
||||
expect(field.controller?.text, isEmpty);
|
||||
expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_send_parent_id_when_replying_to_top_level_comment', (
|
||||
@@ -629,16 +1020,19 @@ void main() {
|
||||
|
||||
await _pumpSheet(tester, repository: repository);
|
||||
await tester.tap(find.byKey(const Key('comment-reply-10')));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget);
|
||||
expect(find.textContaining('Replying to'), findsOneWidget);
|
||||
await tester.enterText(find.byKey(const Key('comment-input')), 'My reply');
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('comment-composer-input')),
|
||||
'My reply',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.tap(find.byKey(const Key('comment-composer-post')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.publishCalls.single.parentId, '10');
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing);
|
||||
expect(find.text('My reply'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -658,6 +1052,8 @@ Future<AuthController> _pumpSheet(
|
||||
WidgetTester tester, {
|
||||
required FakeCommentRepository repository,
|
||||
FakeHaptics? haptics,
|
||||
Locale locale = const Locale('en'),
|
||||
String? episodeId,
|
||||
}) async {
|
||||
final auth = await _auth();
|
||||
await tester.pumpWidget(
|
||||
@@ -671,18 +1067,18 @@ Future<AuthController> _pumpSheet(
|
||||
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
locale: locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
initialRoute: '/comments',
|
||||
routes: {
|
||||
'/': (_) => const Scaffold(backgroundColor: AppColors.ink),
|
||||
'/comments': (_) => const Scaffold(
|
||||
'/comments': (_) => Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: DramaCommentsSheet(drama: _drama),
|
||||
child: DramaCommentsSheet(drama: _drama, episodeId: episodeId),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -9,76 +9,140 @@ import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'should_show_pull_release_and_refreshing_states_and_refresh_once',
|
||||
(tester) async {
|
||||
final refresh = Completer<void>();
|
||||
var calls = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: AppPullToRefresh(
|
||||
onRefresh: () {
|
||||
calls++;
|
||||
return refresh.future;
|
||||
},
|
||||
child: ListView(
|
||||
key: const Key('pull-refresh-list'),
|
||||
physics: AppPullToRefresh.physics,
|
||||
children: const [SizedBox(height: 1200)],
|
||||
),
|
||||
testWidgets('should_freeze_pull_offset_until_refresh_completes', (
|
||||
tester,
|
||||
) async {
|
||||
expect(AppPullToRefresh.physics.parent, isA<ClampingScrollPhysics>());
|
||||
final refresh = Completer<void>();
|
||||
var calls = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: AppPullToRefresh(
|
||||
onRefresh: () {
|
||||
calls++;
|
||||
return refresh.future;
|
||||
},
|
||||
child: ListView(
|
||||
key: const Key('pull-refresh-list'),
|
||||
physics: AppPullToRefresh.physics,
|
||||
children: const [SizedBox(height: 1200)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
|
||||
final list = find.byKey(const Key('pull-refresh-list'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(list));
|
||||
await gesture.moveBy(const Offset(0, 30));
|
||||
await tester.pump();
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find.descendant(of: list, matching: find.byType(Scrollable)).first,
|
||||
);
|
||||
expect(scrollable.position.pixels, lessThan(0));
|
||||
expect(find.text('Pull to refresh'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Pull to refresh')).style?.color,
|
||||
AppColors.blue,
|
||||
);
|
||||
final list = find.byKey(const Key('pull-refresh-list'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(list));
|
||||
await gesture.moveBy(const Offset(0, 30));
|
||||
await tester.pump();
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find.descendant(of: list, matching: find.byType(Scrollable)).first,
|
||||
);
|
||||
expect(scrollable.position.pixels, 0);
|
||||
expect(find.text('Pull to refresh'), findsOneWidget);
|
||||
expect(_contentOffset(tester), greaterThan(0));
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Pull to refresh')).style?.color,
|
||||
AppColors.blue,
|
||||
);
|
||||
|
||||
await gesture.moveBy(const Offset(0, 120));
|
||||
await tester.pump();
|
||||
expect(find.text('Release to refresh'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Release to refresh')).style?.color,
|
||||
AppColors.blue,
|
||||
);
|
||||
await gesture.moveBy(const Offset(0, 120));
|
||||
await tester.pump();
|
||||
expect(find.text('Release to refresh'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Release to refresh')).style?.color,
|
||||
AppColors.blue,
|
||||
);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(find.text('Refreshing…'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Refreshing…')).style?.color,
|
||||
AppColors.blue,
|
||||
);
|
||||
expect(calls, 1);
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(find.text('Refreshing…'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Refreshing…')).style?.color,
|
||||
AppColors.blue,
|
||||
);
|
||||
expect(calls, 1);
|
||||
final releaseOffset = _contentOffset(tester);
|
||||
expect(releaseOffset, greaterThan(56));
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
final duplicate = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('pull-refresh-list'))),
|
||||
);
|
||||
await duplicate.moveBy(const Offset(0, 70));
|
||||
await duplicate.up();
|
||||
await tester.pump();
|
||||
expect(calls, 1);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(_contentOffset(tester), closeTo(releaseOffset, 0.1));
|
||||
|
||||
refresh.complete();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.text('Refreshing…'), findsNothing);
|
||||
expect(find.text('Pull to refresh'), findsNothing);
|
||||
},
|
||||
final duplicate = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('pull-refresh-list'))),
|
||||
);
|
||||
await duplicate.moveBy(const Offset(0, 70));
|
||||
await duplicate.up();
|
||||
await tester.pump();
|
||||
expect(calls, 1);
|
||||
expect(_contentOffset(tester), closeTo(releaseOffset, 0.1));
|
||||
|
||||
refresh.complete();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(_contentOffset(tester), closeTo(0, 0.1));
|
||||
expect(find.text('Refreshing…'), findsNothing);
|
||||
expect(find.text('Pull to refresh'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_animate_back_when_released_below_threshold', (
|
||||
tester,
|
||||
) async {
|
||||
var calls = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: AppPullToRefresh(
|
||||
onRefresh: () async {
|
||||
calls++;
|
||||
},
|
||||
child: ListView(
|
||||
key: const Key('pull-refresh-list'),
|
||||
physics: AppPullToRefresh.physics,
|
||||
children: const [SizedBox(height: 1200)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final list = find.byKey(const Key('pull-refresh-list'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(list));
|
||||
await gesture.moveBy(const Offset(0, 30));
|
||||
await tester.pump();
|
||||
final releaseOffset = _contentOffset(tester);
|
||||
expect(releaseOffset, greaterThan(0));
|
||||
expect(releaseOffset, lessThan(56));
|
||||
expect(find.text('Pull to refresh'), findsOneWidget);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(calls, 0);
|
||||
expect(find.text('Refreshing…'), findsNothing);
|
||||
expect(_contentOffset(tester), greaterThan(0));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(_contentOffset(tester), greaterThan(0));
|
||||
expect(_contentOffset(tester), lessThan(releaseOffset));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(_contentOffset(tester), closeTo(0, 0.1));
|
||||
expect(find.text('Pull to refresh'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
double _contentOffset(WidgetTester tester) {
|
||||
final transform = tester.widget<Transform>(
|
||||
find.byKey(const Key('pull-refresh-content')),
|
||||
);
|
||||
return transform.transform.storage[13];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/widgets/centered_toast.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_replace_previous_toast_when_shown_again', (tester) async {
|
||||
late BuildContext hostContext;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
hostContext = context;
|
||||
return const Scaffold(body: SizedBox.expand());
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
showCenteredToast(hostContext, 'first message');
|
||||
await tester.pump();
|
||||
showCenteredToast(hostContext, 'second message');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
|
||||
expect(find.text('second message'), findsOneWidget);
|
||||
expect(find.text('first message'), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.byKey(const Key('centered-toast')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_allow_underlying_gesture_when_toast_is_visible', (
|
||||
tester,
|
||||
) async {
|
||||
late BuildContext hostContext;
|
||||
var taps = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
hostContext = context;
|
||||
return Scaffold(
|
||||
body: GestureDetector(
|
||||
key: const Key('toast-host'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => taps++,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
showCenteredToast(hostContext, 'tap-through');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tapAt(
|
||||
tester.getCenter(find.byKey(const Key('centered-toast'))),
|
||||
);
|
||||
|
||||
expect(taps, 1);
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
});
|
||||
|
||||
testWidgets('should_replace_toast_when_previous_toast_is_fading_out', (
|
||||
tester,
|
||||
) async {
|
||||
late BuildContext hostContext;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
hostContext = context;
|
||||
return const Scaffold(body: SizedBox.expand());
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
showCenteredToast(hostContext, 'first message');
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
showCenteredToast(hostContext, 'replacement message');
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('replacement message'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
@@ -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}) {}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
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/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/home_feed_controller.dart';
|
||||
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'should_seed_recommend_without_requesting_when_initial_feed_is_ready',
|
||||
() async {
|
||||
final repository = _ScriptedFeedRepository();
|
||||
final controller = HomeFeedController(repository);
|
||||
|
||||
controller.seedRecommend([_drama('seed')]);
|
||||
|
||||
expect(controller.channel, HomeFeedChannel.recommend);
|
||||
expect(controller.dramas.map((drama) => drama.id), ['seed']);
|
||||
expect(controller.loaded, isTrue);
|
||||
expect(repository.calls, isEmpty);
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should_use_prefetched_channel_without_another_request_on_first_select',
|
||||
() async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('anime')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]));
|
||||
final controller = HomeFeedController(repository);
|
||||
controller.seedRecommend([_drama('seed')]);
|
||||
|
||||
await controller.prefetchChannels();
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
expect(controller.dramas.single.id, 'anime');
|
||||
expect(repository.calls.map((call) => call.types), [
|
||||
['ANIME'],
|
||||
['LIVE'],
|
||||
]);
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_keep_recommend_when_prefetch_fails', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => throw StateError('offline'))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]));
|
||||
final controller = HomeFeedController(repository);
|
||||
controller.seedRecommend([_drama('seed')]);
|
||||
|
||||
await controller.prefetchChannels();
|
||||
|
||||
expect(controller.channel, HomeFeedChannel.recommend);
|
||||
expect(controller.dramas.single.id, 'seed');
|
||||
expect(controller.lastError, isNull);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'should_wait_prefetch_without_duplicate_request_when_channel_is_selected',
|
||||
() async {
|
||||
final anime = Completer<FeedPage>();
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() => anime.future)
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]));
|
||||
final controller = HomeFeedController(repository);
|
||||
controller.seedRecommend([_drama('seed')]);
|
||||
|
||||
final prefetch = controller.prefetchChannels();
|
||||
final select = controller.selectChannel(HomeFeedChannel.anime);
|
||||
anime.complete(FeedPage(dramas: [_drama('anime')]));
|
||||
await prefetch;
|
||||
await select;
|
||||
|
||||
expect(
|
||||
repository.calls.where((call) => call.types.contains('ANIME')),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(controller.dramas.single.id, 'anime');
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_request_anime_live_and_recommend_with_expected_types', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('anime')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('recommend')]));
|
||||
final controller = HomeFeedController(repository);
|
||||
controller.seedRecommend([_drama('seed')]);
|
||||
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
await controller.selectChannel(HomeFeedChannel.live);
|
||||
await controller.selectChannel(HomeFeedChannel.recommend);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(repository.calls.map((call) => call.types), [
|
||||
['ANIME'],
|
||||
['LIVE'],
|
||||
[],
|
||||
]);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'should_keep_cached_data_while_silently_refreshing_cached_channel',
|
||||
() async {
|
||||
final refresh = Completer<FeedPage>();
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('anime-old')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
|
||||
..enqueue(() => refresh.future);
|
||||
final controller = HomeFeedController(repository);
|
||||
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
await controller.selectChannel(HomeFeedChannel.live);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
expect(controller.dramas.single.id, 'anime-old');
|
||||
refresh.complete(FeedPage(dramas: [_drama('anime-new')]));
|
||||
await pumpEventQueue();
|
||||
expect(controller.dramas.single.id, 'anime-new');
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_append_unique_items_and_stop_when_has_more_is_false', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(
|
||||
() async => FeedPage(
|
||||
dramas: [_drama('a'), _drama('b')],
|
||||
nextCursor: 'next',
|
||||
hasMore: true,
|
||||
),
|
||||
)
|
||||
..enqueue(
|
||||
() async =>
|
||||
FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false),
|
||||
);
|
||||
final controller = HomeFeedController(repository);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
await controller.loadMore();
|
||||
await controller.loadMore();
|
||||
|
||||
expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']);
|
||||
expect(repository.calls, hasLength(2));
|
||||
expect(repository.calls.last.cursor, 'next');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'should_cache_both_channels_when_responses_complete_out_of_order',
|
||||
() async {
|
||||
final anime = Completer<FeedPage>();
|
||||
final live = Completer<FeedPage>();
|
||||
final animeRefresh = Completer<FeedPage>();
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() => anime.future)
|
||||
..enqueue(() => live.future)
|
||||
..enqueue(() => animeRefresh.future);
|
||||
final controller = HomeFeedController(repository);
|
||||
|
||||
final animeLoad = controller.selectChannel(HomeFeedChannel.anime);
|
||||
final liveLoad = controller.selectChannel(HomeFeedChannel.live);
|
||||
live.complete(FeedPage(dramas: [_drama('live')]));
|
||||
await liveLoad;
|
||||
anime.complete(FeedPage(dramas: [_drama('anime')]));
|
||||
await animeLoad;
|
||||
|
||||
expect(controller.channel, HomeFeedChannel.live);
|
||||
expect(controller.dramas.single.id, 'live');
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
expect(controller.dramas.single.id, 'anime');
|
||||
animeRefresh.complete(FeedPage(dramas: [_drama('anime-refresh')]));
|
||||
await pumpEventQueue();
|
||||
expect(controller.dramas.single.id, 'anime-refresh');
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_retry_automatically_when_returning_to_failed_channel', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => throw StateError('offline'))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('anime-retry')]));
|
||||
final controller = HomeFeedController(repository);
|
||||
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
expect(controller.lastError, contains('offline'));
|
||||
expect(controller.loadingFirstPage, isFalse);
|
||||
|
||||
await controller.selectChannel(HomeFeedChannel.live);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
expect(controller.dramas.single.id, 'anime-retry');
|
||||
expect(repository.calls, hasLength(3));
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_not_report_first_page_loading_without_inflight_request', () {
|
||||
final controller = HomeFeedController(_ScriptedFeedRepository());
|
||||
|
||||
expect(controller.loaded, isFalse);
|
||||
expect(controller.loadingFirstPage, isFalse);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_finish_loading_with_error_when_request_times_out', () async {
|
||||
final pending = Completer<FeedPage>();
|
||||
final repository = _ScriptedFeedRepository()..enqueue(() => pending.future);
|
||||
final controller = HomeFeedController(
|
||||
repository,
|
||||
requestTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
expect(controller.loadingFirstPage, isFalse);
|
||||
expect(controller.loaded, isTrue);
|
||||
expect(controller.lastError, isNotNull);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'should_ignore_old_failure_when_same_channel_new_request_succeeds',
|
||||
() async {
|
||||
final oldRefresh = Completer<FeedPage>();
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(
|
||||
() async => FeedPage(
|
||||
dramas: [_drama('a')],
|
||||
nextCursor: 'next',
|
||||
hasMore: true,
|
||||
),
|
||||
)
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
|
||||
..enqueue(() => oldRefresh.future)
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('b')], hasMore: false));
|
||||
final controller = HomeFeedController(repository);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
await controller.selectChannel(HomeFeedChannel.live);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
await controller.loadMore();
|
||||
oldRefresh.completeError(StateError('old failure'));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(controller.dramas.map((drama) => drama.id), ['a', 'b']);
|
||||
expect(controller.lastError, isNull);
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_keep_old_data_when_refresh_fails', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('old')]))
|
||||
..enqueue(() async => throw StateError('offline'));
|
||||
final controller = HomeFeedController(repository);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
final refreshed = await controller.refreshCurrent();
|
||||
|
||||
expect(refreshed, isFalse);
|
||||
expect(controller.dramas.single.id, 'old');
|
||||
expect(controller.lastError, contains('offline'));
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class FeedCall {
|
||||
const FeedCall({
|
||||
required this.types,
|
||||
required this.cursor,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
final List<String> types;
|
||||
final String? cursor;
|
||||
final int size;
|
||||
}
|
||||
|
||||
class _ScriptedFeedRepository implements CatalogRepository {
|
||||
final List<FeedCall> calls = [];
|
||||
final List<Future<FeedPage> Function()> _responses = [];
|
||||
|
||||
void enqueue(Future<FeedPage> Function() response) {
|
||||
_responses.add(response);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) {
|
||||
calls.add(FeedCall(types: types, cursor: cursor, size: size));
|
||||
return _responses.removeAt(0)();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async => _drama(dramaId);
|
||||
}
|
||||
|
||||
Drama _drama(String id) =>
|
||||
Drama(id: id, title: id, synopsis: id, posterUrl: '');
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user