Author SHA1 Message Date
Zhou Weidong a48433f01d fix(profile): 修复我的收藏与观看历史列表不显示 2026-10-22 13:09:55 +08:00
Zhou Weidong f2ae0c70b6 merge: 收藏卡片展示简介 2026-10-22 12:45:31 +08:00
Zhou Weidong 57f3029018 feat(library): 收藏卡片展示简介 2026-10-22 12:45:27 +08:00
Zhou Weidong 9093700a04 merge: 优化观看历史标题加载 2026-10-22 12:43:06 +08:00
Zhou Weidong 78114fa46d fix(library): 优化观看历史标题加载 2026-10-22 12:42:58 +08:00
Zhou Weidong abfca7a3be merge: 收藏与观看历史支持分页 2026-10-22 12:34:39 +08:00
Zhou Weidong 235b50598a feat(library): 收藏与观看历史支持分页 2026-10-22 12:34:27 +08:00
Zhou Weidong 63d3b0f7ca merge: 增加 Android Studio 调试 HTTP 日志 2026-10-22 12:16:02 +08:00
Zhou Weidong 7a588898fa feat(debug): 增加 HTTP 请求响应日志 2026-10-22 12:15:51 +08:00
Zhou Weidong dc4b60270d merge: 合并进度条雪碧图预览 2026-10-22 11:40:30 +08:00
Zhou Weidong 12b733d111 feat(player): 进度条拖动预览改用雪碧图 2026-10-22 11:40:23 +08:00
Zhou Weidong 89a94f85c2 merge: 合并进度条画面预览 2026-10-22 11:03:25 +08:00
Zhou Weidong 51c56d884b feat(player): 增加进度条拖动画面预览 2026-10-22 11:03:17 +08:00
Zhou Weidong d06dce9753 merge: 合并播放入口无缓冲提示 2026-10-22 10:47:28 +08:00
Zhou Weidong 7ad4681049 fix(player): 收敛播放入口缓冲提示 2026-10-22 10:47:16 +08:00
Zhou Weidong f302b396b7 merge: 合并 Seek 播放位置修复 2026-10-22 10:41:12 +08:00
Zhou Weidong 5268adc999 fix(player): 记住拖动后的实际播放位置 2026-10-22 10:41:01 +08:00
Zhou Weidong 47ca21406a merge: 合并播放位置连续性修复 2026-10-22 10:28:07 +08:00
Zhou Weidong 759a02375a fix(player): 修复详情往返播放位置丢失 2026-10-22 10:27:58 +08:00
Zhou Weidong fe49c6d8b2 merge: 合并屏幕常亮与自动续播 2026-10-22 10:17:53 +08:00
Zhou Weidong a25c500371 feat(player): 保持屏幕常亮并自动恢复播放 2026-10-22 10:17:43 +08:00
Zhou Weidong 5bf10221a8 merge: 合并播放器加载与锁定进度条优化 2026-10-22 10:09:44 +08:00
Zhou Weidong 623c184243 fix(player): 优化加载指示器与锁定进度条 2026-10-22 10:09:33 +08:00
Zhou Weidong 7b068e8eb4 merge: 合并登录异常居中吐司 2026-10-22 09:58:08 +08:00
Zhou Weidong d076c57283 fix(auth): 登录异常改为居中吐司 2026-10-22 09:57:57 +08:00
Zhou Weidong 11e37a92b5 merge: 合并登录异常居中弹窗 2026-10-22 09:50:15 +08:00
Zhou Weidong b0002abf82 feat(auth): 登录异常改为居中弹窗 2026-10-22 09:50:00 +08:00
Zhou Weidong 9b78e43702 merge: 合并 K98.3 视频控制器释放修复 2026-10-21 20:00:34 +08:00
Zhou Weidong f3ec12e3c3 fix(player): 修复切换频道时复用已释放的视频控制器 2026-10-21 20:00:15 +08:00
Zhou Weidong 45a53180d9 merge: 合并 K98.2 feed 更新构建期修复 2026-10-21 19:45:54 +08:00
Zhou Weidong d227ac69fb fix(home): 修复 feed 更新触发的 build 期 setState 2026-10-21 19:45:38 +08:00
Zhou Weidong 862b7e92dd merge: 合并 K98.1 列表直达播放页 2026-10-21 19:33:36 +08:00
Zhou Weidong 8b53adc2be feat(library): 收藏与观看记录直接进入播放页 2026-10-21 19:33:22 +08:00
Zhou Weidong ecdff54f2e merge: 合并 K98 登录键盘避让 2026-10-21 19:27:21 +08:00
Zhou Weidong 6c2f77926b fix(auth): 登录注册与手机号登录键盘避让 2026-10-21 19:26:46 +08:00
Zhou Weidong 86588d7faa build(android): release 包显式剔除 x86_64 原生库 2026-10-21 19:13:27 +08:00
Zhou Weidong 91a622733c build(android): 生产包 abiFilters 仅保留 arm64-v8a 与 armeabi-v7a 2026-10-21 19:10:05 +08:00
Zhou Weidong 08980ff09b docs(readme): 生产包合并 arm64-v8a 与 armeabi-v7a 单包输出 2026-10-21 19:06:56 +08:00
Zhou Weidong 30a6bcccfc docs(readme): 记录 Android 生产打包只保留 arm64-v8a 与 armeabi-v7a 2026-10-21 19:02:57 +08:00
Zhou Weidong a42d3a26ac merge: 合并 K97.1 剧场搜索框圆角调整 2026-10-21 18:55:00 +08:00
Zhou Weidong c278503d0e fix(theater): 减小剧场搜索框圆角 2026-10-21 18:54:49 +08:00
Zhou Weidong 76f347cc35 merge: 合并 K97 iOS 评论键盘避让 2026-10-21 18:47:04 +08:00
Zhou Weidong 349ea12404 fix(comment): 修复 iOS 评论输入弹窗键盘避让 2026-10-21 18:46:43 +08:00
Zhou Weidong dfc33027d8 merge: 合并 K96 剧场分类分页 2026-10-21 18:24:28 +08:00
Zhou Weidong 13a14f3e85 feat(theater): 剧场分类列表服务端分页 2026-10-21 18:24:14 +08:00
Zhou Weidong f8769f2e1d Revert "merge: 合并 K95.2 首切频道封面过渡"
This reverts commit a2a491d056, reversing
changes made to 838ca2ce2a.
2026-10-21 18:07:25 +08:00
Zhou Weidong a2a491d056 merge: 合并 K95.2 首切频道封面过渡 2026-10-21 18:06:17 +08:00
Zhou Weidong c9adf8b399 fix(home): 频道切换用封面过渡 2026-10-21 18:06:09 +08:00
Zhou Weidong 838ca2ce2a merge: 合并 K95.1 首次切换频道无感 2026-10-21 17:54:10 +08:00
Zhou Weidong 677e49fa6e fix(home): 首切频道预取并保留播放画面 2026-10-21 17:53:58 +08:00
Zhou Weidong dfa79bed88 merge: 合并 K95 频道切换加载修复 2026-10-21 17:46:49 +08:00
Zhou Weidong 3c4d65a9b2 fix(home): 修复频道快速切换卡 loading 2026-10-21 17:46:42 +08:00
Zhou Weidong c8392b2f2a merge: 合并 K94 首页类型 Feed 2026-10-21 17:34:02 +08:00
Zhou Weidong ae61d9ec4c feat(home): 首页按内容类型分频道 Feed 并支持分页 2026-10-21 17:33:52 +08:00
Zhou Weidong 4d0be47ee3 merge: 合并 K90 点赞与评论按集 2026-10-21 16:15:44 +08:00
Zhou Weidong 83822a0cbb feat(player): 点赞与评论改为按集 2026-10-21 16:15:29 +08:00
Zhou Weidong e6f34977f5 merge: 合并 K87.1 下拉刷新冻结位移修复 2026-10-21 15:23:51 +08:00
Zhou Weidong 35860e056b fix(ui): 下拉刷新刷新期间冻结下拉位移 2026-10-21 15:23:46 +08:00
Zhou Weidong 01bef7f758 merge: 合并 K87 下拉刷新完成后回弹 2026-10-21 15:12:46 +08:00
Zhou Weidong 45e8707f1e feat(ui): 下拉刷新完成后才回弹 2026-10-21 15:12:41 +08:00
Zhou Weidong d39d8d43c1 merge: 合并 K86.22 评论输入面板安全区修复 2026-10-21 15:00:26 +08:00
Zhou Weidong 3fbfa9ab81 fix(player): 去除评论输入面板安全区导致的收键盘位移 2026-10-21 15:00:20 +08:00
Zhou Weidong 036bd29855 merge: 合并 K86.21 评论输入浮层稳定定位修复 2026-10-21 14:54:12 +08:00
Zhou Weidong d2f2459a96 fix(player): 固定评论输入浮层位置避免收键盘跳动 2026-10-21 14:54:05 +08:00
Zhou Weidong e90217bdfd merge: 合并 K86.20 评论输入浮层打开状态修正 2026-10-21 14:48:10 +08:00
Zhou Weidong ea1a9f424b fix(player): 返回键仅收起评论输入键盘 2026-10-21 14:48:01 +08:00
Zhou Weidong 18a1d06821 merge: 合并 K86.20 评论输入初始键盘与聚焦修正 2026-10-21 14:43:56 +08:00
Zhou Weidong a66cdb2e1b fix(player): 修正评论输入浮层初始键盘高度与聚焦 2026-10-21 14:43:43 +08:00
Zhou Weidong 6c72f8bf22 merge: 合并 K86.19 评论输入实色填充保障 2026-10-21 14:35:54 +08:00
Zhou Weidong c892d9b995 fix(player): 评论输入面板下方改为实色填充 2026-10-21 14:35:40 +08:00
Zhou Weidong 2ae4aedac0 merge: 合并 K86.18 输入浮层实底填充修正 2026-10-21 14:30:04 +08:00
Zhou Weidong db6fbd58ea fix(player): 评论输入浮层补齐键盘区域实底 2026-10-21 14:29:52 +08:00
Zhou Weidong 8dc9d55106 merge: 合并 K86.18 评论输入透明浮层 2026-10-21 14:24:36 +08:00
Zhou Weidong 4908dc9d76 fix(player): 评论输入改为透明浮层并消除收起键盘抖动 2026-10-21 14:24:24 +08:00
Zhou Weidong 5b4526cb97 merge: 合并 K86.17 输入弹窗位置固定修正 2026-10-21 14:15:11 +08:00
Zhou Weidong bac42bc653 fix(player): 键盘收起后保持评论输入弹窗位置 2026-10-21 14:14:58 +08:00
Zhou Weidong bc971c17d5 merge: 合并 K86.17 输入弹窗收起规则修复 2026-10-21 14:10:20 +08:00
Zhou Weidong cb58f649c1 fix(player): 收起键盘不关闭评论输入弹窗 2026-10-21 14:10:09 +08:00
Zhou Weidong f9f77090d9 merge: 合并 K86.16 评论提示与输入弹层修复 2026-10-21 13:59:16 +08:00
Zhou Weidong 5d459dd08a fix(player): 评论成功提示改黑色浮层并去除输入弹窗遮罩 2026-10-21 13:59:04 +08:00
Zhou Weidong c3e6e02e47 merge: 合并 K86.15 评论发布成功提示 2026-10-21 13:47:46 +08:00
Zhou Weidong e86daf559a feat(player): 评论发布成功提示 2026-10-21 13:47:36 +08:00
Zhou Weidong 9c0c1b8982 merge: 合并 K86.14 评论输入紧凑弹窗修正 2026-10-21 13:37:04 +08:00
Zhou Weidong e0024ed123 fix(player): 评论输入弹窗改为紧凑高度 2026-10-21 13:36:52 +08:00
Zhou Weidong c8ac2c9823 merge: 合并 K86.14 评论输入独立弹窗 2026-10-21 13:31:54 +08:00
Zhou Weidong 282f51c24b fix(player): 评论输入改为独立弹窗 2026-10-21 13:31:41 +08:00
Zhou Weidong aa14d0a835 merge: 合并 K86.13 评论面板半屏高度 2026-10-21 13:20:33 +08:00
Zhou Weidong 50edbf40c4 fix(player): 评论面板固定半屏高度 2026-10-21 13:20:28 +08:00
Zhou Weidong fc877f25bf merge: 合并 K86.12 评论文字居中修复 2026-10-21 13:16:39 +08:00
Zhou Weidong f87b0d2689 fix(player): 评论入口文字垂直居中 2026-10-21 13:16:34 +08:00
Zhou Weidong 146cd2a29e merge: 合并 K86.11 评论假输入框入口 2026-10-21 13:12:39 +08:00
Zhou Weidong 425ea4c0b7 fix(player): 评论入口改为假输入框点击唤起 2026-10-21 13:12:30 +08:00
Zhou Weidong a142a6c318 merge: 合并 K86.10 评论面板高度与收起流程 2026-10-21 13:04:40 +08:00
Zhou Weidong d081cff3e4 fix(player): 评论面板保持高度并两段式收起 2026-10-21 13:04:29 +08:00
Zhou Weidong 9876772d06 merge: 合并 K86.9 评论面板键盘态布局修复 2026-10-21 13:00:20 +08:00
Zhou Weidong bc4c84a4ae fix(player): 修正评论面板键盘态布局重叠 2026-10-21 13:00:05 +08:00
Zhou Weidong a7c0576a03 merge: 合并 K86.8 评论列表与焦点保持 2026-10-21 12:53:56 +08:00
Zhou Weidong ad69ef7f54 fix(player): 聚焦时保留评论列表并稳定焦点 2026-10-21 12:53:46 +08:00
Zhou Weidong de99c09454 merge: 合并 K86.7 键盘弹出不压缩视频页 2026-10-21 12:48:01 +08:00
Zhou Weidong 1776c622ae fix(player): 键盘弹出不压缩视频页 2026-10-21 12:47:52 +08:00
Zhou Weidong 33b49c161a merge: 合并 K86.6 评论输入聚焦修复 2026-10-21 12:40:25 +08:00
Zhou Weidong c7b230263a fix(player): 修复评论输入聚焦时面板消失 2026-10-21 12:40:12 +08:00
Zhou Weidong 226ac5920c merge: 合并 K86.5 隐藏评论操作入口 2026-10-21 12:26:26 +08:00
Zhou Weidong 9bd46521aa fix(player): 评论暂时隐藏举报与删除入口 2026-10-21 12:26:14 +08:00
Zhou Weidong 838beaaa3e merge: 合并 K86.4 评论展开输入面板 2026-10-21 12:21:28 +08:00
Zhou Weidong 519cab20fe fix(player): 评论聚焦态改为展开输入面板 2026-10-21 12:21:17 +08:00
Zhou Weidong 8b51a7c18e merge: 合并 K86.3 Android 键盘窗口修正 2026-10-21 12:14:19 +08:00
Zhou Weidong dbeb9883a5 fix(android): 键盘弹出不再压缩整页 2026-10-21 12:14:09 +08:00
Zhou Weidong 5160ab437d merge: 合并 K86.2 评论面板间距与键盘修正 2026-10-21 12:07:39 +08:00
Zhou Weidong ab476cab7d fix(player): 评论面板自适应高度并只顶起输入框 2026-10-21 12:07:30 +08:00
Zhou Weidong 97735763a1 merge: 合并 K86.1 评论面板补修 2026-10-21 12:00:37 +08:00
Zhou Weidong 2659c29501 fix(player): 评论总数含回复并改用相对时间 2026-10-21 12:00:27 +08:00
140 changed files with 12365 additions and 1055 deletions

No files matched your search

+21
View File
@@ -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
+11
View File
@@ -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 = "../.."
}
+1 -1
View File
@@ -17,7 +17,7 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustNothing">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@@ -5,17 +5,40 @@ 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)
@@ -62,8 +85,133 @@ class MainActivity : FlutterActivity() {
result.error("share_failed", error.message, null)
}
}
keyboardChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.stormg.lumadrama/keyboard"
)
keyboardChannel?.setMethodCallHandler { call, result ->
if (call.method != "get") {
result.notImplemented()
return@setMethodCallHandler
}
val inset = currentImeInset()
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)
ViewCompat.onApplyWindowInsets(view, insets)
}
ViewCompat.requestApplyInsets(decorView)
}
private fun currentImeInset(): Int =
ViewCompat.getRootWindowInsets(window.decorView)
?.getInsets(WindowInsetsCompat.Type.ime())
?.bottom
?: 0
private fun emitImeInset(value: Int) {
if (value == imeInset) return
imeInset = value
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)
@@ -77,4 +225,21 @@ class MainActivity : FlutterActivity() {
pendingShare = null
}
}
override fun onDestroy() {
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。
+732
View File
@@ -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` 既有改动未提交。
+86
View File
@@ -1,5 +1,24 @@
import Flutter
import UIKit
import AVFoundation
private final class ThumbnailResponseGate {
private let lock = NSLock()
private var completed = false
func send(_ result: @escaping FlutterResult, data: Data?) {
lock.lock()
if completed {
lock.unlock()
return
}
completed = true
lock.unlock()
DispatchQueue.main.async {
result(data)
}
}
}
/// Keeps the system LaunchScreen as the only splash. Flutter would otherwise
/// instantiate that storyboard again and the icon appears a second time.
@@ -16,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)
}
}
}
}
+7
View File
@@ -42,6 +42,7 @@ import 'core/data/services/favorite_controller.dart';
import 'core/data/services/interaction_controller.dart';
import 'core/data/services/locale_controller.dart';
import 'core/data/services/event_tracker.dart';
import 'core/data/services/playback_position_cache.dart';
import 'core/data/services/playback_session.dart';
import 'core/data/services/video_player_port.dart';
import 'core/data/services/wallet_controller.dart';
@@ -72,6 +73,7 @@ class LumaDramaApp extends StatefulWidget {
this.watchHistoryRepository,
this.favoriteRepository,
this.playbackRepository,
this.playbackPositionCache,
this.commentRepository,
this.interactionRepository,
this.noticeRepository,
@@ -91,6 +93,7 @@ class LumaDramaApp extends StatefulWidget {
final WatchHistoryRepository? watchHistoryRepository;
final FavoriteRepository? favoriteRepository;
final PlaybackRepository? playbackRepository;
final PlaybackPositionCache? playbackPositionCache;
final CommentRepository? commentRepository;
final InteractionRepository? interactionRepository;
final NoticeRepository? noticeRepository;
@@ -146,6 +149,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
widget.playbackRepository ??
HttpPlaybackRepository(PlaybackApi(_apiClient));
late final PlaybackPositionCache _playbackPositionCache =
widget.playbackPositionCache ?? PlaybackPositionCache();
late final FavoriteController _favoriteController = FavoriteController(
_favorites,
);
@@ -240,6 +246,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
Provider<CatalogRepository>.value(value: _catalogRepository),
Provider<PlaybackRepository>.value(value: _playback),
Provider<PlaybackPositionCache>.value(value: _playbackPositionCache),
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository),
+23 -1
View File
@@ -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;
}
+31
View File
@@ -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 -1
View File
@@ -2,9 +2,10 @@
import 'json_numbers.dart';
/// 互动目标类型,契约只允许短剧与评论。
/// 互动目标类型。
enum InteractionTargetType {
drama('DRAMA'),
episode('EPISODE'),
comment('COMMENT');
const InteractionTargetType(this.apiValue);
+47
View File
@@ -0,0 +1,47 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端标准分页响应 `{total, list}`。
///
/// 兼容旧版「data 直接是数组」的响应:按数组长度作为 total,避免列表空白。
class PageResult<T> {
const PageResult({this.total = 0, this.list = const []});
final int total;
final List<T> list;
/// 解析分页壳,条目由调用方提供具体解析函数。
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) parseItem,
) {
final items = _parseItemMaps(json['list']).map(parseItem).toList(
growable: false,
);
return PageResult(total: jsonToInt(json['total']), list: items);
}
/// 兼容分页 Map 与旧版纯 List;其它类型视为空页。
factory PageResult.parse(
Object? data,
T Function(Map<String, dynamic>) parseItem,
) {
if (data is Map) {
return PageResult.fromJson(Map<String, dynamic>.from(data), parseItem);
}
if (data is List) {
final items = _parseItemMaps(data).map(parseItem).toList(growable: false);
return PageResult(total: items.length, list: items);
}
return PageResult<T>();
}
}
List<Map<String, dynamic>> _parseItemMaps(Object? raw) {
if (raw is! List) return const [];
return [
for (final item in raw)
if (item is Map) Map<String, dynamic>.from(item),
];
}
+18
View File
@@ -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;
}
+20
View File
@@ -12,6 +12,10 @@ class WatchProgress {
required this.episodeId,
required this.position,
required this.duration,
this.dramaTitle,
this.dramaCover,
this.episodeNumber,
this.totalEpisodes,
this.finished = false,
this.playCount = 0,
this.updatedAt,
@@ -19,6 +23,10 @@ class WatchProgress {
final String dramaId;
final String episodeId;
final String? dramaTitle;
final String? dramaCover;
final int? episodeNumber;
final int? totalEpisodes;
final Duration position;
final Duration duration;
final bool finished;
@@ -37,6 +45,14 @@ class WatchProgress {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
dramaTitle: json['dramaTitle']?.toString(),
dramaCover: json['dramaCover']?.toString(),
episodeNumber: json['episodeNumber'] == null
? null
: jsonToInt(json['episodeNumber']),
totalEpisodes: json['totalEpisodes'] == null
? null
: jsonToInt(json['totalEpisodes']),
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
@@ -48,6 +64,10 @@ class WatchProgress {
Map<String, dynamic> toJson() => {
'dramaId': dramaId,
'episodeId': episodeId,
if (dramaTitle != null) 'dramaTitle': dramaTitle,
if (dramaCover != null) 'dramaCover': dramaCover,
if (episodeNumber != null) 'episodeNumber': episodeNumber,
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'finished': finished,
+21 -1
View File
@@ -19,8 +19,10 @@ class WatchRecord {
String get dramaId => progress.dramaId;
/// 详情中按 episodeId 定位集号;缺失时返回 0。
/// 优先使用分页接口集号,其次从已加载详情定位。
int get episodeNumber {
final pageEpisode = progress.episodeNumber;
if (pageEpisode != null && pageEpisode > 0) return pageEpisode;
final episodes = drama?.episodes ?? const <Episode>[];
for (var index = 0; index < episodes.length; index++) {
if (episodes[index].id == progress.episodeId) return index + 1;
@@ -28,6 +30,24 @@ class WatchRecord {
return 0;
}
/// 服务端分页标题优先,详情标题作为回退。
String get displayTitle {
final pageTitle = progress.dramaTitle?.trim() ?? '';
return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? '';
}
/// 服务端分页封面优先,详情封面作为回退。
String get displayCover {
final pageCover = progress.dramaCover?.trim() ?? '';
return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? '';
}
/// 分页接口已带展示字段时无需等待详情加载。
bool get hasDisplayMetadata =>
displayTitle.isNotEmpty ||
progress.dramaCover?.trim().isNotEmpty == true ||
(progress.episodeNumber ?? 0) > 0;
/// 生成卡片加载中、成功或失败后的新状态。
WatchRecord copyWith({
Drama? drama,
+4
View File
@@ -1,8 +1,11 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import 'api_exception.dart';
import 'api_interceptors.dart';
import 'network_log_interceptor.dart';
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
export 'api_exception.dart';
@@ -39,6 +42,7 @@ class ApiClient {
deviceIdReader: deviceIdReader,
),
const EnvelopeInterceptor(),
NetworkLogInterceptor(),
]);
if (adapter != null) {
_dio.httpClientAdapter = adapter;
@@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'api_exception.dart';
import 'network_log_interceptor.dart';
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
class ApiRequestFlags {
@@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor {
ResponseInterceptorHandler handler,
) {
final options = response.requestOptions;
if (NetworkLogInterceptor.isEnabled) {
options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] =
response.data;
}
final shell = _decodeShell(response.data);
final code = shell['code'] as int? ?? -1;
final message = shell['message'] as String?;
+24
View File
@@ -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, {
+4 -2
View File
@@ -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);
}
+11 -7
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/page_result.dart';
import 'api_client.dart';
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
@@ -20,13 +21,16 @@ class LibraryApi {
return data == true;
}
Future<List<Drama>> favorites() async {
final data = await _client.get('/app/favorites');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
];
Future<PageResult<Drama>> favorites({
int pageIndex = 1,
int pageSize = 20,
}) async {
final data = await _client.get(
'/app/favorites',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
return PageResult.parse(data, Drama.fromCardJson);
}
Future<void> removeFavorite(String dramaId) async {
@@ -0,0 +1,219 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// 调试构建下输出 Dio 请求、响应和异常详情。
class NetworkLogInterceptor extends Interceptor {
/// 在调试时关闭日志的本地常量开关。
static const bool enabled = true;
/// Envelope 解包前暂存原始响应体,供链尾日志读取。
static const String rawResponseDataExtraKey = 'app.http.rawResponseData';
/// 请求、响应体单条日志最大字符数。
static const int maxBodyLength = 3000;
static const String _truncatedSuffix = '…(truncated)';
static const String _stopwatchKey = 'app.http.stopwatch';
static const String _startedAtKey = 'app.http.startedAt';
static const String _emptyBody = '<empty>';
static const String _redacted = '***';
/// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。
NetworkLogInterceptor({void Function(String)? logger})
: _logger = logger ?? debugPrint;
final void Function(String) _logger;
/// release/profile 下恒为 false,保证零 HTTP 日志输出。
static bool get isEnabled => kDebugMode && enabled;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!isEnabled) {
return handler.next(options);
}
final stopwatch = Stopwatch()..start();
options.extra[_stopwatchKey] = stopwatch;
options.extra[_startedAtKey] = DateTime.now().toIso8601String();
final body = _formatRequestBody(options.data);
final message = StringBuffer()
..writeln('[HTTP] → ${options.method} ${options.uri}')
..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}')
..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}')
..writeln('[HTTP] → body:$body');
_logger(message.toString().trimRight());
handler.next(options);
}
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
if (!isEnabled) {
return handler.next(response);
}
final options = response.requestOptions;
final elapsed = _elapsedMilliseconds(options);
final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data;
final body = _formatResponseBody(
rawBody,
response.headers.value(Headers.contentTypeHeader),
);
final message = StringBuffer()
..writeln(
'[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms '
'${options.method} ${options.uri}',
)
..writeln('[HTTP] ← body:$body');
_logger(message.toString().trimRight());
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (!isEnabled) {
return handler.next(err);
}
final options = err.requestOptions;
final message = StringBuffer()
..writeln(
'[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms '
'${options.uri} ${err.message ?? ''}',
)
..write(_errorBodyLine(err));
_logger(message.toString().trimRight());
handler.next(err);
}
int _elapsedMilliseconds(RequestOptions options) {
final stopwatch = options.extra[_stopwatchKey];
if (stopwatch is Stopwatch) {
return stopwatch.elapsedMilliseconds;
}
return 0;
}
String _errorBodyLine(DioException error) {
final rawBody =
error.requestOptions.extra[rawResponseDataExtraKey] ??
error.response?.data;
if (rawBody == null) {
return '';
}
final body = _formatResponseBody(
rawBody,
error.response?.headers.value(Headers.contentTypeHeader),
);
return '\n[HTTP] ✗ body:$body';
}
String _formatHeaders(Map<String, dynamic> headers) {
if (headers.isEmpty) {
return '{}';
}
final entries = headers.entries.toList()
..sort((left, right) => left.key.compareTo(right.key));
final safe = <String, String>{
for (final entry in entries)
entry.key: _safeHeaderValue(entry.key, entry.value),
};
return _truncate(jsonEncode(safe));
}
String _safeHeaderValue(String name, Object? value) {
final text = value?.toString() ?? '';
if (name.toLowerCase() == 'authorization') {
return _maskAuthorization(text);
}
if (name.toLowerCase() == 'x-device-id') {
return text;
}
return _redacted;
}
String _maskAuthorization(String value) {
if (value.length <= 8) {
return _redacted;
}
return '${value.substring(0, math.min(8, value.length))}...';
}
String _formatRequestBody(Object? body) {
if (body == null) {
return _emptyBody;
}
final json = _tryPrettyJson(body);
if (json != null) {
return _truncate(json);
}
final length = switch (body) {
FormData data => data.length,
List<int> bytes => bytes.length,
_ => utf8.encode(body.toString()).length,
};
return '${body.runtimeType} ($length bytes)';
}
String _formatResponseBody(Object? body, String? contentType) {
if (body == null) {
return _emptyBody;
}
if (body is List<int>) {
final bytes = body is Uint8List ? body : Uint8List.fromList(body);
if (_isBinary(contentType)) {
return '<binary ${contentType ?? 'unknown'}, ${bytes.length} bytes>';
}
final text = utf8.decode(bytes, allowMalformed: true);
return _formatText(text);
}
return _formatText(body.toString());
}
String _formatText(String text) {
if (text.isEmpty) {
return _emptyBody;
}
return _truncate(_tryPrettyJson(text) ?? text);
}
String? _tryPrettyJson(Object? body) {
try {
final value = body is String
? jsonDecode(body)
: body is List<int>
? jsonDecode(utf8.decode(body, allowMalformed: true))
: body;
if (value is! Map && value is! List) {
return null;
}
return const JsonEncoder.withIndent(' ').convert(value);
} catch (_) {
return null;
}
}
bool _isBinary(String? contentType) {
final type = contentType?.toLowerCase() ?? '';
return type.startsWith('image/') ||
type.startsWith('audio/') ||
type.startsWith('video/') ||
type.startsWith('font/') ||
type.contains('application/octet-stream') ||
type.contains('application/pdf') ||
type.contains('application/zip');
}
String _truncate(String value) {
if (value.length <= maxBodyLength) {
return value;
}
final end = maxBodyLength - _truncatedSuffix.length;
return '${value.substring(0, end)}$_truncatedSuffix';
}
}
+9 -8
View File
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import '../models/page_result.dart';
import '../models/watch_progress.dart';
import 'api_client.dart';
@@ -39,17 +40,17 @@ class WatchApi {
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
/// 最近观看分页(服务端已按更新时间倒序)。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
final data = await _client.get(
'/app/watch/recent',
query: {'limit': '$limit'},
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
];
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
return PageResult.parse(data, WatchProgress.fromJson);
}
/// 删除某部剧的观看记录(记录页清单条用)。
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/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,
+102 -8
View File
@@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart';
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
static const int defaultPageSize = 20;
final FavoriteRepository _repository;
final int pageSize;
final Set<String> _saved = {};
List<Drama> cards = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
String? error;
String? loadMoreError;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
@@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier {
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
notify();
try {
final list = await _repository.list();
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
if (_disposed || request != _requestSeq) return;
final list = _uniqueCards(page.list);
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
error = 'favoritesError';
if (!_disposed && request == _requestSeq) {
error = 'favoritesError';
}
} finally {
loading = false;
notify();
if (!_disposed && request == _requestSeq) {
loading = false;
notify();
}
}
}
/// 追加下一页;正在刷新、已在加载或无更多时直接忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
notify();
try {
final page = await _repository.page(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (_disposed || request != _requestSeq) return;
final known = cards.map((drama) => drama.id).toSet();
final appended = _uniqueCards(
page.list,
).where((drama) => known.add(drama.id)).toList(growable: false);
cards = [...cards, ...appended];
_saved.addAll(appended.map((drama) => drama.id));
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
if (!_disposed && request == _requestSeq) {
loadMoreError = 'favoritesError';
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
notify();
}
}
}
@@ -52,10 +107,17 @@ class FavoriteController extends ChangeNotifier {
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
final previousCards = cards;
final previousTotal = total;
final previousHasMore = hasMore;
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
} else {
_saved.add(dramaId);
}
@@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier {
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
if (wasSaved) {
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
total += 1;
hasMore = cards.length < total;
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
}
notify();
return true;
} catch (_) {
// 回滚
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
if (wasSaved) {
_saved.add(dramaId);
} else {
@@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier {
_saved.remove(dramaId);
final next = [...cards]..removeAt(index);
cards = next;
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
notify();
try {
await _repository.remove(dramaId);
@@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier {
);
cards = restored;
_saved.add(dramaId);
total += 1;
hasMore = cards.length < total;
notify();
return false;
}
@@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier {
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_requestSeq += 1;
_saved.clear();
cards = const [];
total = 0;
loading = false;
hasMore = false;
loadingMore = false;
loadMoreError = null;
error = null;
_pageIndex = 0;
notify();
}
List<Drama> _uniqueCards(List<Drama> source) {
final seen = <String>{};
return [
for (final drama in source)
if (seen.add(drama.id)) drama,
];
}
@override
void dispose() {
_disposed = true;
@@ -0,0 +1,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;
}
}
+52 -4
View File
@@ -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;
}
}
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
import '../../l10n/app_localizations.dart';
/// Formats a timestamp as a relative label, falling back to a compact local date.
String formatRelativeDateTime(
DateTime value,
AppLocalizations l10n, {
DateTime? now,
}) {
final localValue = value.toLocal();
final localNow = (now ?? DateTime.now()).toLocal();
final elapsed = localNow.difference(localValue);
if (elapsed.isNegative || elapsed.inMinutes < 1) return l10n.justNow;
if (elapsed.inMinutes < 60) return l10n.minutesAgo(elapsed.inMinutes);
if (elapsed.inHours < 24) return l10n.hoursAgo(elapsed.inHours);
if (localValue.year == localNow.year) {
return '${_twoDigits(localValue.month)}-${_twoDigits(localValue.day)}';
}
return '${localValue.year}-${_twoDigits(localValue.month)}-'
'${_twoDigits(localValue.day)}';
}
String _twoDigits(int value) => value.toString().padLeft(2, '0');
+69
View File
@@ -0,0 +1,69 @@
// 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 {
KeyboardInsetNotifier._();
static final KeyboardInsetNotifier instance = KeyboardInsetNotifier._();
static const MethodChannel _channel = MethodChannel(
'com.stormg.lumadrama/keyboard',
);
/// Raw physical-pixel inset reported by Android.
int _inset = 0;
bool _started = false;
int get value => _inset;
Future<void> start() async {
if (_started || kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return;
}
_started = true;
_channel.setMethodCallHandler((call) async {
if (call.method != 'insetsChanged') return;
_update(call.arguments);
});
try {
_update(await _channel.invokeMethod<Object?>('get'));
} catch (_) {
// Tests and non-embedded Android hosts may not provide the channel.
}
}
void _update(Object? value) {
final next = switch (value) {
int value => value,
num value => value.round(),
_ => 0,
};
if (next == _inset) return;
_inset = next;
notifyListeners();
}
}
+92 -45
View File
@@ -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(
+128
View File
@@ -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,
),
),
),
),
),
),
);
}
}
+86
View File
@@ -0,0 +1,86 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 网络分页列表底部状态:加载、重试、手动加载与无更多。
class PagedListFooter extends StatelessWidget {
const PagedListFooter({
super.key,
required this.loading,
required this.hasMore,
required this.hasItems,
required this.onRetry,
this.errorMessage,
});
final bool loading;
final bool hasMore;
final bool hasItems;
final String? errorMessage;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (errorMessage != null) {
return Padding(
key: const Key('paged-list-error'),
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
Text(
errorMessage!,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
TextButton(
key: const Key('paged-list-retry'),
onPressed: () => unawaited(onRetry()),
child: Text(l10n.retry),
),
],
),
);
}
if (loading) {
return const SizedBox(
key: Key('paged-list-loading'),
height: 56,
child: Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.blue,
),
),
),
);
}
if (hasMore) {
return Center(
child: TextButton(
key: const Key('paged-list-load-more'),
onPressed: () => unawaited(onRetry()),
child: Text(l10n.loadMore),
),
);
}
if (hasItems) {
return Padding(
key: const Key('paged-list-no-more'),
padding: const EdgeInsets.symmetric(vertical: 14),
child: Text(
l10n.noMore,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
);
}
return const SizedBox(height: 16);
}
}
+8
View File
@@ -100,6 +100,13 @@
"count": {"type": "int"}
}
},
"justNow": "Just now",
"hoursAgo": "{count} hours ago",
"@hoursAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "Artwork",
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
"videoPending": "Video pending",
@@ -204,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.",
+8
View File
@@ -79,6 +79,13 @@
"count": {"type": "int"}
}
},
"justNow": "Baru saja",
"hoursAgo": "{count} jam lalu",
"@hoursAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap",
@@ -178,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.",
+18
View File
@@ -508,6 +508,18 @@ abstract class AppLocalizations {
/// **'{count} min ago'**
String minutesAgo(int count);
/// No description provided for @justNow.
///
/// In en, this message translates to:
/// **'Just now'**
String get justNow;
/// No description provided for @hoursAgo.
///
/// In en, this message translates to:
/// **'{count} hours ago'**
String hoursAgo(int count);
/// No description provided for @artworkOnly.
///
/// In en, this message translates to:
@@ -934,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:
+11
View File
@@ -227,6 +227,14 @@ class AppLocalizationsEn extends AppLocalizations {
return '$count min ago';
}
@override
String get justNow => 'Just now';
@override
String hoursAgo(int count) {
return '$count hours ago';
}
@override
String get artworkOnly => 'Artwork';
@@ -454,6 +462,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get postComment => 'Post';
@override
String get commentPostSuccess => 'Comment posted';
@override
String get loadMore => 'Load more';
+11
View File
@@ -227,6 +227,14 @@ class AppLocalizationsId extends AppLocalizations {
return '$count mnt lalu';
}
@override
String get justNow => 'Baru saja';
@override
String hoursAgo(int count) {
return '$count jam lalu';
}
@override
String get artworkOnly => 'Gambar';
@@ -455,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';
+11
View File
@@ -224,6 +224,14 @@ class AppLocalizationsZh extends AppLocalizations {
return '$count 分钟前';
}
@override
String get justNow => '刚刚';
@override
String hoursAgo(int count) {
return '$count 小时前';
}
@override
String get artworkOnly => '海报展示';
@@ -449,6 +457,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get postComment => '发布';
@override
String get commentPostSuccess => '发表成功';
@override
String get loadMore => '加载更多';
+8
View File
@@ -79,6 +79,13 @@
"count": {"type": "int"}
}
},
"justNow": "刚刚",
"hoursAgo": "{count} 小时前",
"@hoursAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供",
@@ -178,6 +185,7 @@
"writeComment": "写评论",
"commentLoginHint": "登录后参与评论",
"postComment": "发布",
"commentPostSuccess": "发表成功",
"loadMore": "加载更多",
"commentsLoadFailed": "评论加载失败,请重试。",
"commentPostFailed": "评论发布失败,请重试。",
+83 -6
View File
@@ -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
@@ -184,7 +260,7 @@ class _AuthPageState extends State<AuthPage> {
return Theme(
data: AppTheme.light(),
child: Scaffold(
resizeToAvoidBottomInset: true,
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.ink,
body: Stack(
children: [
@@ -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(
+96 -9
View File
@@ -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)),
),
);
+77 -11
View File
@@ -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(
+63 -16
View File
@@ -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(
+14
View File
@@ -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!];
}
+13 -1
View File
@@ -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) {
+162 -28
View File
@@ -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(
@@ -247,6 +272,7 @@ class _HomeTabsState extends State<_HomeTabs> {
child: Scaffold(
key: logic.scaffoldKey,
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
),
@@ -276,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,
+3 -2
View File
@@ -22,13 +22,14 @@ class MineLogic extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
/// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。
/// 最近观看:取分页首屏,剧名封面仍由目录卡片按 id 映射。
Future<void> refreshRecent() async {
recentLoading = true;
recentError = null;
notify();
try {
recent = await history.recent(limit: 20);
final page = await history.recent(pageIndex: 1, pageSize: 20);
recent = page.list;
} catch (_) {
recentError = 'progressError';
} finally {
+3
View File
@@ -4,6 +4,7 @@ import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/wallet_controller.dart';
import 'player_logic.dart';
@@ -11,11 +12,13 @@ class PlayerBinding {
PlayerLogic create({
required WatchHistoryRepository history,
required PlaybackRepository playback,
required PlaybackPositionCache positionCache,
WalletController? wallet,
DownloadController? downloads,
}) => PlayerLogic(
history: history,
playback: playback,
positionCache: positionCache,
pictureInPicture: AndroidPictureInPicture(),
wallet: wallet,
downloads: downloads,
+400 -52
View File
@@ -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);
+161 -26
View File
@@ -6,12 +6,16 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/comment_repository.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/sprite_thumbnail_axis.dart';
import '../../core/data/services/playback_thumbnail_service.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/error_text.dart';
@@ -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) {
@@ -291,6 +406,7 @@ class _PlayerPageState extends State<PlayerPage>
: Scaffold(
key: const Key('player-scaffold'),
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: body,
),
),
@@ -310,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.
@@ -322,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,
@@ -350,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,
),
),
),
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
@@ -383,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(
@@ -449,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(
@@ -480,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 {
@@ -493,6 +620,8 @@ class _PlayerPageState extends State<PlayerPage>
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
)
else
PlayerPlaybackOverlay(
@@ -501,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),
@@ -512,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
@@ -524,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),
+2 -2
View File
@@ -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),
),
),
),
),
],
),
);
}
}
+12 -59
View File
@@ -3,26 +3,17 @@
import 'package:flutter/material.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/formatting/localized_date_time.dart';
import '../../../core/formatting/relative_date_time.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
const String _deleteAction = 'delete';
const String _reportAction = 'report';
/// 评论条目的交互回调集合。
class CommentInteractionHandlers {
const CommentInteractionHandlers({
required this.onReply,
required this.onDelete,
required this.onReport,
});
const CommentInteractionHandlers({required this.onReply});
final ValueChanged<DramaComment> onReply;
final Future<void> Function(DramaComment) onDelete;
final Future<void> Function(DramaComment) onReport;
}
/// 单条评论或回复的展示与操作行。
@@ -46,7 +37,7 @@ class CommentTile extends StatelessWidget {
: comment.nickname;
return Padding(
key: Key('comment-item-${comment.id}'),
padding: const EdgeInsets.only(top: 12, bottom: 14),
padding: const EdgeInsets.only(top: 10, bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -56,51 +47,13 @@ class CommentTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
name,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
SizedBox.square(
dimension: 28,
child: PopupMenuButton<String>(
key: Key('comment-menu-${comment.id}'),
tooltip: l10n.more,
padding: EdgeInsets.zero,
iconSize: 18,
icon: const Icon(
Icons.more_horiz_rounded,
color: AppColors.subtle,
),
onSelected: (value) {
if (value == _deleteAction) {
handlers.onDelete(comment);
} else if (value == _reportAction) {
handlers.onReport(comment);
}
},
itemBuilder: (_) => [
if (comment.mine)
PopupMenuItem(
value: _deleteAction,
child: Text(l10n.deleteComment),
),
PopupMenuItem(
value: _reportAction,
child: Text(l10n.reportComment),
),
],
),
),
],
Text(
name,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 2),
Text(
@@ -125,7 +78,7 @@ class CommentTile extends StatelessWidget {
),
if (comment.createdAt != null || !isReply)
Padding(
padding: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.only(top: 4),
child: _metadata(context, l10n),
),
],
@@ -164,7 +117,7 @@ class CommentTile extends StatelessWidget {
children: [
if (createdAt != null)
Text(
formatLocalizedDateTime(context, createdAt),
formatRelativeDateTime(createdAt, l10n),
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
),
if (createdAt != null && !isReply) ...[
+252 -217
View File
@@ -1,197 +1,232 @@
// Author: fruit.com.cn
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/theme/app_colors.dart';
import '../../../core/widgets/auth_gate.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import '../../interaction/widgets/report_dialog.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;
const double _sheetRadius = 20;
const double _headerHeight = 44;
const double _replyIndent = 52;
const double _composerTopGap = 8;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、举报和本人删除。
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。
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();
late final CommentRepository _comments;
late final CommentController _controller;
DramaComment? _replyTo;
OverlayEntry? _composerOverlay;
OverlayEntry? _successOverlay;
Timer? _successOverlayTimer;
int? _serverCount;
int _countRequest = 0;
@override
void initState() {
super.initState();
_comments = context.read<CommentRepository>();
_controller = CommentController(
comments: context.read<CommentRepository>(),
comments: _comments,
interactions: context.read<InteractionRepository>(),
dramaId: widget.drama.id,
episodeId: widget.episodeId,
);
unawaited(_controller.load());
unawaited(_refreshCount());
}
@override
void dispose() {
_removeComposerOverlay();
_successOverlayTimer?.cancel();
_successOverlay?.remove();
_controller.dispose();
_input.dispose();
_inputFocus.dispose();
super.dispose();
}
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);
FocusScope.of(context).unfocus();
return;
}
_showMessage(
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
);
}
void _beginReply(DramaComment comment) {
setState(() => _replyTo = comment);
_inputFocus.requestFocus();
unawaited(_openComposer(replyTo: comment));
}
void _cancelReply() {
if (_replyTo == null) return;
setState(() => _replyTo = null);
Future<void> _refreshCount() async {
final request = ++_countRequest;
try {
final count = await _comments.count(
widget.drama.id,
episodeId: widget.episodeId,
);
if (!mounted || request != _countRequest) return;
setState(() => _serverCount = count);
} catch (_) {
if (!mounted || request != _countRequest) return;
setState(() => _serverCount = null);
}
}
Future<void> _authenticateForComment() async {
_inputFocus.unfocus();
Future<void> _openComposer({DramaComment? replyTo}) async {
await ensureSignedIn(context);
if (!mounted || !context.read<AuthController>().isSignedIn) return;
_inputFocus.requestFocus();
}
Future<void> _confirmDelete(DramaComment comment) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.deleteCommentTitle),
content: Text(l10n.deleteCommentMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
key: const Key('comment-delete-confirm'),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.delete),
),
],
_removeComposerOverlay();
final entry = OverlayEntry(
builder: (_) => CommentComposerSheet(
controller: _controller,
replyTo: replyTo,
onDismiss: _closeComposer,
onPosted: _handleComposerPosted,
),
);
if (confirmed != true || !mounted) return;
final result = await _controller.delete(comment);
if (!result.isSuccess && mounted) {
_showMessage(
result.errorMessage ??
AppLocalizations.of(context)!.commentDeleteFailed,
);
setState(() => _composerOverlay = entry);
Overlay.of(context, rootOverlay: true).insert(entry);
}
void _handleComposerPosted() {
_closeComposer();
_showPostSuccess();
unawaited(_refreshCount());
}
void _closeComposer() {
if (!mounted) {
_removeComposerOverlay();
return;
}
setState(_removeComposerOverlay);
}
Future<void> _report(DramaComment comment) async {
if (!await ensureSignedIn(context) || !mounted) return;
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 draft = await showReportDialog(context, title: l10n.reportComment);
if (draft == null || !mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await _controller.report(
comment,
reason: draft.reason,
description: draft.description,
);
if (!mounted) return;
_showMessage(
result.isSuccess
? l10n.reportSubmitted
: result.errorMessage ?? l10n.reportFailed,
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;
});
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
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 available =
MediaQuery.sizeOf(context).height -
MediaQuery.viewInsetsOf(context).bottom -
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
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: SizedBox(
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
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) {
final l10n = AppLocalizations.of(context)!;
final count = _controller.exactCount;
final count = _serverCount ?? _controller.exactCount;
final signedIn = context.watch<AuthController>().isSignedIn;
final composerReserve = _composerHeight + _composerTopGap;
return Column(
key: const Key('comment-sheet-content'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
@@ -230,52 +265,60 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
],
),
),
const SizedBox(height: 8),
Expanded(child: _list(l10n)),
if (_replyTo != null) _replyBanner(l10n, _replyTo!),
_composer(l10n),
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) {
Widget _list(AppLocalizations l10n, {required double composerReserve}) {
if (_controller.loading && _controller.comments.isEmpty) {
return const Center(
child: CircularProgressIndicator(key: Key('comment-list-loading')),
return const Padding(
padding: EdgeInsets.only(top: 4),
child: Center(
child: CircularProgressIndicator(key: Key('comment-list-loading')),
),
);
}
if (_controller.error != null && _controller.comments.isEmpty) {
return EmptyState(
key: const Key('comment-list-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, _controller.error, l10n.commentsLoadFailed),
actionLabel: l10n.retry,
actionKey: const Key('comment-list-retry'),
onAction: _controller.load,
compact: true,
return Padding(
padding: const EdgeInsets.only(top: 4),
child: EmptyState(
key: const Key('comment-list-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, _controller.error, l10n.commentsLoadFailed),
actionLabel: l10n.retry,
actionKey: const Key('comment-list-retry'),
onAction: _controller.load,
compact: true,
),
);
}
if (_controller.comments.isEmpty) {
return EmptyState(
key: const Key('comment-list-empty'),
icon: Icons.chat_bubble_outline_rounded,
title: l10n.noComments,
compact: true,
return Padding(
padding: const EdgeInsets.only(top: 4),
child: EmptyState(
key: const Key('comment-list-empty'),
icon: Icons.chat_bubble_outline_rounded,
title: l10n.noComments,
compact: true,
),
);
}
return ListView.builder(
key: const Key('comment-list'),
padding: EdgeInsets.only(top: 4, bottom: composerReserve),
itemCount: _controller.comments.length + 1,
itemBuilder: (context, index) {
if (index == _controller.comments.length) return _footer(l10n);
return _CommentThread(
comment: _controller.comments[index],
controller: _controller,
handlers: CommentInteractionHandlers(
onReply: _beginReply,
onDelete: _confirmDelete,
onReport: _report,
),
handlers: CommentInteractionHandlers(onReply: _beginReply),
);
},
);
@@ -298,98 +341,90 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
return const SizedBox(height: 8);
}
Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(
l10n.replyingTo(
replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname,
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: [
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,
),
),
),
),
),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
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),
),
),
],
),
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) {
const radius = BorderRadius.all(Radius.circular(_composerRadius));
const clear = OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide.none,
);
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
counterText: '',
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
isDense: true,
border: clear,
enabledBorder: clear,
focusedBorder: clear,
),
);
}
}
Widget _composer(AppLocalizations l10n) {
final signedIn = context.watch<AuthController>().isSignedIn;
return Container(
key: const Key('comment-composer'),
height: _composerHeight,
padding: const EdgeInsets.only(right: 3),
decoration: BoxDecoration(
color: AppColors.commentInput,
borderRadius: BorderRadius.circular(_composerRadius),
),
child: Row(
children: [
Expanded(
child: SizedBox(
height: _composerHeight,
child: TextField(
key: const Key('comment-input'),
controller: _input,
focusNode: _inputFocus,
readOnly: !signedIn,
canRequestFocus: signedIn,
maxLength: _maxCommentLength,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(color: AppColors.cream, fontSize: 14),
cursorColor: AppColors.blue,
decoration: _composerDecoration(
signedIn ? l10n.writeComment : l10n.commentLoginHint,
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),
),
onTap: signedIn ? null : _authenticateForComment,
onSubmitted: (_) => _post(),
),
),
),
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),
),
),
],
),
),
);
}
+29 -44
View File
@@ -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';
}
+153 -73
View File
@@ -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,
),
],
),
);
}
+57 -17
View File
@@ -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),
+216
View File
@@ -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 {
+142
View File
@@ -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++;
+14 -1
View File
@@ -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);
});
}
+80
View File
@@ -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);
},
);
}
+14 -3
View File
@@ -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 {
+38
View File
@@ -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(
File diff suppressed because it is too large. Load diff
+127 -63
View File
@@ -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];
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more