Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48433f01d | ||
|
|
f2ae0c70b6 | ||
|
|
57f3029018 | ||
|
|
9093700a04 | ||
|
|
78114fa46d | ||
|
|
abfca7a3be | ||
|
|
235b50598a | ||
|
|
63d3b0f7ca | ||
|
|
7a588898fa | ||
|
|
dc4b60270d | ||
|
|
12b733d111 | ||
|
|
89a94f85c2 | ||
|
|
51c56d884b | ||
|
|
d06dce9753 | ||
|
|
7ad4681049 | ||
|
|
f302b396b7 | ||
|
|
5268adc999 | ||
|
|
47ca21406a | ||
|
|
759a02375a | ||
|
|
fe49c6d8b2 | ||
|
|
a25c500371 | ||
|
|
5bf10221a8 | ||
|
|
623c184243 | ||
|
|
7b068e8eb4 | ||
|
|
d076c57283 | ||
|
|
11e37a92b5 | ||
|
|
b0002abf82 | ||
|
|
9b78e43702 | ||
|
|
f3ec12e3c3 | ||
|
|
45a53180d9 | ||
|
|
d227ac69fb | ||
|
|
862b7e92dd | ||
|
|
8b53adc2be | ||
|
|
ecdff54f2e | ||
|
|
6c2f77926b | ||
|
|
86588d7faa | ||
|
|
91a622733c | ||
|
|
08980ff09b | ||
|
|
30a6bcccfc | ||
|
|
a42d3a26ac | ||
|
|
c278503d0e | ||
|
|
76f347cc35 | ||
|
|
349ea12404 | ||
|
|
dfc33027d8 | ||
|
|
13a14f3e85 | ||
|
|
f8769f2e1d | ||
|
|
a2a491d056 | ||
|
|
c9adf8b399 | ||
|
|
838ca2ce2a | ||
|
|
677e49fa6e | ||
|
|
dfa79bed88 | ||
|
|
3c4d65a9b2 | ||
|
|
c8392b2f2a | ||
|
|
ae61d9ec4c | ||
|
|
4d0be47ee3 | ||
|
|
83822a0cbb | ||
|
|
e6f34977f5 | ||
|
|
35860e056b | ||
|
|
01bef7f758 | ||
|
|
45e8707f1e | ||
|
|
d39d8d43c1 | ||
|
|
3fbfa9ab81 | ||
|
|
036bd29855 | ||
|
|
d2f2459a96 | ||
|
|
e90217bdfd | ||
|
|
ea1a9f424b | ||
|
|
18a1d06821 | ||
|
|
a66cdb2e1b | ||
|
|
6c72f8bf22 | ||
|
|
c892d9b995 | ||
|
|
2ae4aedac0 | ||
|
|
db6fbd58ea | ||
|
|
8dc9d55106 | ||
|
|
4908dc9d76 | ||
|
|
5b4526cb97 | ||
|
|
bac42bc653 | ||
|
|
bc971c17d5 | ||
|
|
cb58f649c1 | ||
|
|
f9f77090d9 | ||
|
|
5d459dd08a | ||
|
|
c3e6e02e47 | ||
|
|
e86daf559a | ||
|
|
9c0c1b8982 | ||
|
|
e0024ed123 | ||
|
|
c8ac2c9823 | ||
|
|
282f51c24b | ||
|
|
aa14d0a835 | ||
|
|
50edbf40c4 | ||
|
|
fc877f25bf | ||
|
|
f87b0d2689 | ||
|
|
146cd2a29e | ||
|
|
425ea4c0b7 | ||
|
|
a142a6c318 | ||
|
|
d081cff3e4 | ||
|
|
9876772d06 | ||
|
|
bc4c84a4ae | ||
|
|
a7c0576a03 | ||
|
|
ad69ef7f54 | ||
|
|
de99c09454 | ||
|
|
1776c622ae | ||
|
|
33b49c161a | ||
|
|
c7b230263a | ||
|
|
226ac5920c | ||
|
|
9bd46521aa | ||
|
|
838beaaa3e | ||
|
|
519cab20fe | ||
|
|
8b51a7c18e | ||
|
|
dbeb9883a5 | ||
|
|
5160ab437d | ||
|
|
ab476cab7d | ||
|
|
97735763a1 | ||
|
|
2659c29501 | ||
|
|
22cb74ded4 | ||
|
|
7862e0be09 | ||
|
|
bdd3a53632 | ||
|
|
5e2b8d9e4d | ||
|
|
97bbced02c | ||
|
|
cbb9f60f7f | ||
|
|
35de4095b0 | ||
|
|
4b1aea29c7 | ||
|
|
e6a31a60df | ||
|
|
622dc2f9e8 | ||
|
|
62f87040d6 | ||
|
|
7fccd16750 | ||
|
|
c06a67c4b0 | ||
|
|
1993ff1102 | ||
|
|
72708630ba | ||
|
|
d6b2d308eb | ||
|
|
0ba6ea9c52 | ||
|
|
cf4b07aa69 | ||
|
|
76cc52ad66 | ||
|
|
99b45e4f12 | ||
|
|
c0697c01e3 | ||
|
|
8c04099d97 | ||
|
|
3a636ca894 | ||
|
|
d13b601ae8 | ||
|
|
827c972da0 | ||
|
|
92bfc76e50 | ||
|
|
1dbc3acd80 | ||
|
|
66e9062ba5 | ||
|
|
ac1202c329 | ||
|
|
0c6a103c1c | ||
|
|
51c363540a | ||
|
|
9ebb54ecfd | ||
|
|
2c5ff3579c | ||
|
|
3fefd544fb |
No files matched your search
@@ -135,6 +135,27 @@ flutter run -d <device> \
|
||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
## 生产打包(Android)
|
||||
|
||||
生产包只包含以下两个 ABI,**打成一个 APK**,不做 split:
|
||||
|
||||
| ABI | 适用设备 |
|
||||
| --- | --- |
|
||||
| `arm64-v8a` | 现代手机(64 位) |
|
||||
| `armeabi-v7a` | 老 32 位机 |
|
||||
|
||||
```bash
|
||||
flutter build apk --release --target-platform android-arm64,android-arm
|
||||
# 产出单个:build/app/outputs/flutter-apk/app-release.apk(约 37MB)
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。
|
||||
- `x86_64` 只用于模拟器,不进入生产包。
|
||||
- 不要直接运行 `flutter build apk --release`:默认会编译三套原生库,包体约 57MB。
|
||||
- 只有需要进一步压缩单机下载体积时,才考虑 `--split-per-abi` 出分架构包(arm64 约 21MB、armv7 约 19MB),默认不用。
|
||||
|
||||
## 门禁
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,6 +29,10 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// 生产包只保留 arm64-v8a 与 armeabi-v7a,避免插件带入 x86_64 原生库
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
@@ -42,6 +46,13 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// 生产包不允许出现 x86_64:部分插件 AAR 会带入少量 x86_64 原生库,这里显式剔除。
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
variant.packaging.jniLibs.excludes.add("lib/x86_64/**")
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@@ -3,5 +3,4 @@
|
||||
|
||||
<resources>
|
||||
<color name="splash_background">#1F1B16</color>
|
||||
<color name="ic_launcher_background">#FFF1E5</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1004 B |
@@ -0,0 +1,24 @@
|
||||
# Android Studio 调试 HTTP 日志
|
||||
|
||||
## 目标
|
||||
|
||||
- 仅在调试构建中输出 Dio 请求、响应与异常日志。
|
||||
- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。
|
||||
- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。
|
||||
- 单条请求体或响应体最多 3000 字符。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。
|
||||
2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。
|
||||
3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。
|
||||
4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。
|
||||
5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。
|
||||
- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取,
|
||||
避免拿到已解包后的 `data`。
|
||||
- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。
|
||||
- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 收藏卡片简介
|
||||
|
||||
## 目标
|
||||
|
||||
- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。
|
||||
- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。
|
||||
- 保持封面 80x106 和现有卡片高度,不引入布局溢出。
|
||||
|
||||
## 实现
|
||||
|
||||
1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。
|
||||
2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间,
|
||||
`maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。
|
||||
3. 无简介时继续使用原 Spacer,保持原布局。
|
||||
4. widget 测试覆盖有简介、空简介和卡片高度稳定性。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不改接口、后端、收藏分页。
|
||||
- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 屏幕常亮与前台自动续播
|
||||
|
||||
## 目标
|
||||
|
||||
- Android 与 iOS 前台保持屏幕常亮,不引入插件或依赖。
|
||||
- 用户主动暂停后切后台再回前台,在播放器与票据状态安全时自动续播。
|
||||
- 锁定、blocked、票据失败、错误态及浮层遮挡场景不得强制起播。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. Android `MainActivity.onCreate` 添加 `FLAG_KEEP_SCREEN_ON`,保留现有渠道逻辑。
|
||||
2. iOS `AppDelegate` 设置 `UIApplication.shared.isIdleTimerDisabled = true`。
|
||||
3. 为 `PlaybackSession` 增加无浮层、路线可见且 active 播放器可用的恢复判断。
|
||||
4. 调整 `PlayerLogic.onLifecycle`,在现有 PiP 复位和过期票据刷新流程后安全设置播放意图。
|
||||
5. 补用户暂停恢复、锁定/票据失败不恢复、后台保存并暂停测试。
|
||||
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 真机常亮和生命周期行为由 PM 验收,本次不启停服务、不操作设备。
|
||||
- PiP 继续沿用现有可见性策略,不额外修改后台播放行为。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- Android 主 Activity 已添加 `FLAG_KEEP_SCREEN_ON`,未改动 Flutter 渠道与 PiP 逻辑。
|
||||
- iOS AppDelegate 已设置 `UIApplication.shared.isIdleTimerDisabled = true`。
|
||||
- 用户主动暂停后回前台可安全恢复;过期票据会等待重取完成后再恢复。
|
||||
- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景均不会强制续播。
|
||||
- 后台仍先保存进度并暂停。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 580 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 收藏与观看历史分页 UI
|
||||
|
||||
## 目标
|
||||
|
||||
- `/app/favorites` 与 `/app/watch/recent` 按 `PageResult{total,list}` 分页接入。
|
||||
- 收藏页、观看历史页支持首屏、触底加载、无更多、刷新、失败重试和总数展示。
|
||||
- 观看记录直接使用后端 `dramaTitle/dramaCover/episodeNumber`,缺失时安全兜底。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 新增通用 `PageResult<T>`,更新 LibraryApi、WatchApi、Repository 与内存替身。
|
||||
2. FavoriteController、WatchRecordsController 增加分页状态、请求代次、dramaId 去重。
|
||||
3. 页面通过滚动通知触发 `loadMore`,复用下拉刷新并增加通用分页 footer。
|
||||
4. 观看卡片优先渲染接口字段,标题为空时用本地化“第 N 集”,封面为空显示占位。
|
||||
5. 补 API、Controller 和 widget 测试,运行 analyze 与全量 flutter test。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- 刷新期间保持旧列表,成功后原子替换并回到第 1 页;失败保留旧数据。
|
||||
- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。
|
||||
- 空页立即停止加载;`loaded >= total` 后不再请求。
|
||||
- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。
|
||||
|
||||
## 2026-10-22 范围修订
|
||||
|
||||
- 分页协议和 Controller 分页状态保持不改。
|
||||
- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。
|
||||
- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。
|
||||
- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。
|
||||
- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页,
|
||||
只在 PROGRESS 记录证据,不做前端全量兜底。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 登录异常居中弹窗
|
||||
|
||||
## 目标
|
||||
|
||||
- 邮箱登录、邮箱注册及手机号 OTP 登录的提交异常改为屏幕居中弹窗。
|
||||
- 本地表单校验继续使用现有内联字段错误,不触发弹窗。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 新增 `lib/core/widgets/centered_error_dialog.dart`,统一弹窗样式、遮罩关闭和确认按钮。
|
||||
2. 补充登录失败、注册失败、确认按钮的中英文印尼语文案并重新生成 l10n。
|
||||
3. 调整邮箱认证页:接口异常展示居中弹窗,提交 busy 在展示弹窗前复位。
|
||||
4. 调整手机号弹窗:OTP 发送和验证码登录/注册异常展示居中弹窗,成功 SnackBar 保持不变。
|
||||
5. 补接口异常、注册标题、本地校验不弹窗和手机号两条异常路径测试。
|
||||
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 同级 FruitServer 的 `INBOX.md` 当前顶部为已处理 K100,未找到本需求条目;以用户消息中的完整需求为准。
|
||||
- 现有主工作区处于 `feature/payment-iap` 且有未提交 `pubspec.lock`,本次使用独立 worktree,不触碰该文件。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已新增居中错误弹窗组件及三语言文案,邮箱登录/注册和手机号 OTP/验证码提交异常均改为弹窗。
|
||||
- 本地字段校验仍走原内联错误;异常展示前提交 busy 已复位。
|
||||
- 接口异常、注册标题、未知异常网络文案、遮罩关闭、确认关闭、本地校验不弹窗均有测试覆盖。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 570 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 登录异常居中吐司
|
||||
|
||||
## 目标
|
||||
|
||||
- 将上一版认证异常 AlertDialog 替换为无交互的屏幕居中吐司。
|
||||
- 吐司淡入展示约 2 秒后自动淡出,不遮罩、不拦截手势。
|
||||
- 同一时刻只存在一个吐司,新消息替换旧消息。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 删除 `centered_error_dialog.dart`,新增 `centered_toast.dart`。
|
||||
2. 认证页和手机号弹窗异常路径改用 `showCenteredToast`,保留 busy 复位和成功 SnackBar。
|
||||
3. 清理不再使用的认证错误标题与确认按钮 l10n 键,重新生成 AppLocalizations。
|
||||
4. 更新认证测试为吐司断言,并补单例替换、自动消失测试。
|
||||
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 吐司使用 root Overlay,认证路由退出时仍会按自身计时器自动清理。
|
||||
- 测试必须 pump 超过展示和淡出时长,确保不遗留 Timer 或 OverlayEntry。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已删除 AlertDialog 组件,新增 OverlayEntry 居中吐司。
|
||||
- 邮箱和手机号异常改用吐司;busy 复位、成功 SnackBar 和本地内联校验保持不变。
|
||||
- 已清理三个不再使用的 l10n 键并重新生成 AppLocalizations。
|
||||
- 覆盖单例替换、点击穿透、自动消失、接口异常和本地校验不弹吐司。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 573 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 播放入口无缓冲提示
|
||||
|
||||
## 目标
|
||||
|
||||
- 未初始化进度条显示静态 inactive 轨道,不出现流动加载高光。
|
||||
- 仅真实 buffering 持续超过 350ms 时显示白色高光。
|
||||
- 起播定位阶段不叠加 session 居中 loading;只有 ticket loading 居中显示。
|
||||
- 同集返回复用 controller;重建时 initialize→seek→play。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 调整 SeekProgressBar 与 _FeedProgressBar 的初始化/缓冲状态判断。
|
||||
2. 删除 PlayerView 的 session loading 居中圈,仅保留 ticket loading。
|
||||
3. preparePlayer 统一通过 VideoPlayerPort.seekTo 定位缓存位置。
|
||||
4. 更新进度条、全屏入口、详情返回和重建顺序测试。
|
||||
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 真实持续网络卡顿仍保留缓冲高光。
|
||||
- 锁定集继续沿用静态空轨道和禁用 seek 行为。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 播放位置连续性
|
||||
|
||||
## 目标
|
||||
|
||||
- 首页进入详情、详情返回首页、详情再进播放器时保持同一集同一秒。
|
||||
- 新增进程内最近播放位置缓存,优先于服务端旧记录。
|
||||
- 跨剧、跨集不串位;锁定/票据失败不因缓存强行起播;进程重启仍回服务端。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId/position/duration/updatedAt。
|
||||
2. App Provider 注入单一缓存实例,PlayerPage 与详情页共享。
|
||||
3. PlayerLogic 进度监听、saveCurrentProgress 写入缓存;loadResumePoint 优先命中缓存。
|
||||
4. preparePlayer 对每个待准备集按 dramaId 校验缓存,并继续使用现有位置钳位。
|
||||
5. 详情页续看状态优先使用该剧最新缓存,未命中再请求服务端。
|
||||
6. 补缓存、缓存命中不请求服务端、跨剧隔离、服务端回落和详情返回位置测试。
|
||||
7. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 缓存仅保存在进程内,App 重启后自动回落到服务端观看记录。
|
||||
- 片尾附近继续复用现有 `clampResumePosition` 完成保护。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 新增进程内按集缓存并通过 Provider 全局共享,详情页与播放器共用。
|
||||
- 进度监听、`saveCurrentProgress` 和进入详情前会写入缓存。
|
||||
- `loadResumePoint` 优先缓存,未命中或跨剧时才请求服务端。
|
||||
- 详情页继续观看与 CTA 优先进缓存集;返回首页保持原播放器与位置。
|
||||
- 已覆盖缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落和详情往返。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 589 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Seek 后播放位置保持
|
||||
|
||||
## 目标
|
||||
|
||||
- 拖动进度条后,缓存与观看记录立即使用 seek 完成后的真实位置。
|
||||
- 拖动后立即进详情/离开也不会回退到旧进度。
|
||||
- 越界位置 clamp,锁定/票据失败不写无效缓存。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 增加播放位置 clamp 纯函数,缓存写入统一限制在 `0..duration`。
|
||||
2. VideoPlayerPort 暴露 seek 方法,PlayerLogic 等待 seek 完成后再读取 controller。
|
||||
3. PlayerLogic 统一当前播放快照,监听、缓存和上报共用同一份 position/duration。
|
||||
4. 增加 pending seek 屏障,saveCurrentProgress 与离开路径等待 seek 和上报完成。
|
||||
5. 补 seek 缓存、越界 clamp、seek 后进详情并返回测试。
|
||||
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 片尾与完成逻辑继续沿用现有 controller/播放器行为。
|
||||
- 缓存仅在进程内,不改变服务端观看记录契约。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 播放器 loading 与锁定进度条调整
|
||||
|
||||
## 目标
|
||||
|
||||
- 播放器票据与 session loading 圈统一为 24×24、线宽 2。
|
||||
- 缓冲/加载进度高光改为白色。
|
||||
- 未解锁剧集显示静态 inactive 轨道,不进入 loading,不允许拖动 seek。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 更新 `LoadingProgressTrack` 默认高光颜色,并补颜色断言。
|
||||
2. 为 `SeekProgressBar`、`PlayerOverlayData`、`HomeFeedOverlay` 和 `_FeedProgressBar` 增加 locked 状态。
|
||||
3. `PlayerPage` 使用 `logic.session.isLocked || episode.locked` 传入两个浮层。
|
||||
4. 缩小 `player-ticket-loading` 与 `player-session-loading` 的尺寸和线宽。
|
||||
5. 补首页/全屏锁定态、缓冲高光和两个 loading 指示器尺寸测试。
|
||||
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 锁定态只影响进度条 UI 与手势,不改播放、解锁或接口逻辑。
|
||||
- 首页浮层锁定线沿用当前 `Colors.white24` inactive 色,避免改变非锁定轨道样式。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 两个播放器 loading 圈均为 24×24、线宽 2、主色不变。
|
||||
- loading 高光默认改为白色,普通轨道与首页 white24 轨道颜色未变。
|
||||
- 首页与全屏播放器均按剧集锁定态渲染静态空轨道并禁用 seek。
|
||||
- 已覆盖锁定轨道高度、无 loading、无拖动 seek、loading 指示器尺寸和高光颜色。
|
||||
- `dart analyze lib test` 零告警,`flutter test --no-pub` 576 项全过。
|
||||
- 未启停服务,未操作真机。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 播放器进度拖动画面预览
|
||||
|
||||
## 目标
|
||||
|
||||
首页 feed 与全屏播放器的进度条在拖动时显示当前时间点的视频画面缩略图、
|
||||
当前时间与总时长;松手立即隐藏并提交 seek。缩略图未返回或失败时显示短剧
|
||||
封面,不出现空白闪烁或错误文案。
|
||||
|
||||
## 性能红线
|
||||
|
||||
1. 拖动期间主线程无卡顿;图片解码和原生取帧不得阻塞 UI 线程。
|
||||
2. 原生取帧并发数不超过 1。
|
||||
3. 预览图最大宽度 240px。
|
||||
4. Dart 内存 LRU 缓存不超过 24 帧。
|
||||
5. 不新增 pub 依赖。
|
||||
6. 不改后端契约。
|
||||
|
||||
## 实现
|
||||
|
||||
### 缩略图服务
|
||||
|
||||
- 新增 `PlaybackThumbnailService`,公开入口按 `episodeId + position` 取
|
||||
`ByteData`,底层通过
|
||||
`MethodChannel('com.stormg.lumadrama/thumbnail')` 调用原生。
|
||||
- 时间按 1 秒分桶;同一 bucket 的在途请求合流。
|
||||
- 最多 24 帧 LRU;成功和失败结果都做过期控制,避免旧拖动位置覆盖当前预览。
|
||||
- 全局单飞;拖动期间只保留最新 bucket,尚未开始的旧请求直接丢弃。
|
||||
- 拖动停顿 120ms 后最多预取相邻一个 bucket,松手取消尚未开始的请求。
|
||||
- 原生异常、缺失实现和超过 1.5 秒无结果统一返回 `null`。
|
||||
|
||||
### 原生取帧
|
||||
|
||||
- Android 使用 `MediaMetadataRetriever`,优先本地下载路径,其次签名 URL;
|
||||
`OPTION_CLOSEST_SYNC` 取帧,最大宽 240px,JPEG 质量 70,单线程 Executor,
|
||||
完成后立即 release。
|
||||
- iOS 使用 `AVAssetImageGenerator`,应用画面方向,最大尺寸约 240x426,
|
||||
合理时间容差,后台串行队列生成 JPEG。
|
||||
- 两端均保证只回调一次;失败或 1.5 秒超时返回 `null`,不向 UI 抛异常。
|
||||
|
||||
### UI
|
||||
|
||||
- 新增 `ScrubPreviewCard`:宽 120dp、圆角 8、白色 1px 描边,显示
|
||||
`当前时间 / 总时长`;沿拖动点水平跟随并做安全边距 clamp。
|
||||
- 首页 `_FeedProgressBar` 与全屏 `SeekProgressBar` 共用该卡片。
|
||||
- 封面始终铺在卡片底层;`Image.memory` 仅在帧到达后覆盖,并限制
|
||||
`cacheWidth: 240`。
|
||||
- 开始拖动请求当前 bucket,拖动更新只保留最新位置;松手立即隐藏卡片并取消
|
||||
未开始请求。
|
||||
- `PlayerPage` 持有页面级服务,dispose 时清空缓存,避免跨页面串集。
|
||||
|
||||
## 测试
|
||||
|
||||
- 单测:bucket、LRU 淘汰、同 bucket 合流、过期请求丢弃、松手取消、
|
||||
相邻 bucket 防抖、异常/超时回落。
|
||||
- Widget:拖动显示预览卡片和时间、松手消失、无缩略图显示封面。
|
||||
- 门禁:`dart analyze lib test` 与 `flutter test --no-pub`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改服务端接口或持久化策略。
|
||||
- 不新增第三方依赖。
|
||||
- 不启停服务、不操作真机;原生取帧实际效果由 PM 真机验收。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 雪碧图 VTT 拖动预览
|
||||
|
||||
## 目标
|
||||
|
||||
将进度条拖动预览主链路改为服务端雪碧图 + WebVTT 局部裁剪,原生取帧只作为降级:
|
||||
|
||||
1. `Episode` 与 `PlayTicket` 解析 `thumbnailsUrl`。
|
||||
2. VTT 只拉取/解析一次,内存缓存 10 分钟。
|
||||
3. cue 使用起止时间 + 相对 sprite 文件名 + `xywh`,按 position 二分选择。
|
||||
4. sprite 图最多缓存 2 张 `ui.Image`,拖动帧只绘制 sourceRect。
|
||||
5. 无 VTT、空轴或图片失败时回退现有 `PlaybackThumbnailService`,再回退封面。
|
||||
|
||||
## 性能红线
|
||||
|
||||
- 拖动帧禁止网络请求与图片解码,只允许 cue 二分查找和 `RawImage.sourceRect` 绘制。
|
||||
- VTT 每个 URL 只解析一次;sprite 解码不在 build/build paint 中执行。
|
||||
- sprite ui.Image LRU 上限 2 张,页面 dispose 清空。
|
||||
- 解码 sprite 最长边超过 2048px 时按比例缩小后常驻。
|
||||
- 签名 `thumbnailsUrl` 不写入任何持久化缓存。
|
||||
|
||||
## 实施
|
||||
|
||||
- 新增 `SpriteThumbnailAxisService`:
|
||||
- Dio 拉取 VTT,403 清缓存后重试一次;
|
||||
- 解析和选 cue 为纯函数,解析失败返回空轴;
|
||||
- 播放器就绪时预取当前 cue 及相邻 sprite;
|
||||
- LRU2 sprite 缓存和 10 分钟 VTT 缓存。
|
||||
- `PlayerPage` 持有页面级服务,按当前选中集预取,dispose 时清空。
|
||||
- `ScrubPreviewCard` 优先读取已缓存 sprite frame,显示 `RawImage` 裁剪;
|
||||
VTT 未就绪时暂显封面,轴为空/不可用后启动本地取帧,全部失败保留封面。
|
||||
- 首页 feed 和全屏播放器都把 `thumbnailsUrl` 传到预览卡片。
|
||||
- 不新增 pub 依赖、不改后端契约。
|
||||
|
||||
## 测试
|
||||
|
||||
- VTT 解析:时间、xywh、多 sprite 文件。
|
||||
- cue 选择:命中、越界最近邻。
|
||||
- sprite LRU 最多 2 张。
|
||||
- 回退状态判定。
|
||||
- Widget:sprite 裁剪且不触发本地取帧;无 URL 回退本地;全部失败显示封面。
|
||||
- 门禁:`dart analyze lib test`、`flutter test --no-pub`。
|
||||
|
||||
## 风险
|
||||
|
||||
- 真实网络 VTT/sprite 可访问性和签名过期由 PM 真机验收。
|
||||
- 本次不启动服务、不操作真机、不修改 Web/FruitServer。
|
||||
@@ -0,0 +1,732 @@
|
||||
# 开发进度
|
||||
|
||||
## 2026-10-21 K96 剧场页服务端分类分页
|
||||
|
||||
- 已完整读取 K96 `docs/plans/INBOX.md`。
|
||||
- 已从最新 `origin/develop` 创建 ReelShort `feat/k96-theater-feed`。
|
||||
- 计划见 FruitServer `docs/plans/2026-10-21-K96-theater-feed.md`。
|
||||
- 客户端将新增 TheaterFeedController,并让剧场「全部 / 分类」改用
|
||||
`/app/feed` 服务端游标分页;不改首页 K94/K95 已交付逻辑。
|
||||
- 已完成 `CatalogApi.feed` / `CatalogRepository.fetchFeed` 可选 `categoryId`,
|
||||
非空才拼请求参数。
|
||||
- 已新增 `TheaterFeedController`:按分类独立缓存,缓存先渲染后静默刷新,
|
||||
10 秒超时、游标追加去重、失败保留旧数据、失败分类切回自动重试。
|
||||
- 剧场页「全部」和分类 chips 已改用 theater feed;滚动剩余不足 400px 触发
|
||||
`loadMore`,下拉刷新同时执行 categories 与 theater 刷新,搜索模式保持独立。
|
||||
- 已补分类参数、缓存切换、分页去重、hasMore 停止、失败保留、失败重试、
|
||||
分类点击请求和滚动加载 widget 测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:52 +553: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 24.8s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K97 修复 iOS 评论输入弹窗键盘避让
|
||||
|
||||
- 已完整读取 K97 `docs/plans/INBOX.md`。
|
||||
- 已从最新 `origin/develop` 创建 `fix/k97-ios-comment-keyboard`。
|
||||
- 计划见 FruitServer `docs/plans/2026-10-21-K97-ios-comment-keyboard.md`。
|
||||
- Android 保留既有 pinned/channel 策略;iOS 与其他平台改用实时
|
||||
`MediaQuery.viewInsets.bottom`,并补 iOS 键盘避让测试。
|
||||
- 已完成 `_isAndroid` 平台分流;Android 的 channel、80ms 防抖和 pinned 逻辑保持不变,
|
||||
iOS/其他平台直接读取实时 viewInsets,超过屏幕 60% 的异常值按 0 处理。
|
||||
- 面板已使用 160ms `easeOutCubic` AnimatedPadding;Android 不包裹 SafeArea,
|
||||
iOS/其他平台使用 `SafeArea(top: false)` 处理 home indicator。
|
||||
- 已补 iOS insets=300/0 测试,发表按钮在键盘上方;Android pinned 既有用例全部通过。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:54 +555: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 26.2s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
|
||||
$ flutter build ios --simulator --no-codesign
|
||||
Xcode build done. 44.0s
|
||||
✓ Built build/ios/iphonesimulator/Runner.app
|
||||
```
|
||||
|
||||
解读:静态分析零告警,555 项全量测试通过,Android 与 iOS simulator 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K97.1 剧场搜索框圆角改小
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k97.1-theater-search-radius`。
|
||||
- 仅将剧场搜索框圆角从 22 调整为 12,高度 44、颜色、聚焦态及
|
||||
清空/识剧逻辑保持不变,并同步更新对应 widget 断言。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:55 +555: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 25.8s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,555 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98 修复登录与手机号登录键盘遮挡
|
||||
|
||||
- 已完整读取 K98 `docs/plans/INBOX.md`。
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98-auth-keyboard`。
|
||||
- 计划见 FruitServer `docs/plans/2026-10-21-K98-auth-keyboard.md`。
|
||||
- 将统一 viewInsets/channel 有效键盘高度,并给登录页与手机号弹窗增加
|
||||
焦点/键盘变化后的字段滚动校正。
|
||||
- 已新增 `effectiveKeyboardInset`,兼容 iOS 实时 viewInsets 与 Android
|
||||
`adjustNothing` 下的 MethodChannel 键盘高度。
|
||||
- 登录/注册页已增加 ScrollController、notifier 监听和邮箱/密码焦点校正;
|
||||
手机号弹窗已同样接入手机号/验证码框滚动校正。
|
||||
- 已补 iOS 登录/注册、Android channel、手机号验证码框键盘避让测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:52 +559: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 26.2s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,559 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98.1 收藏与观看记录直接进入播放页
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98.1-play-from-lists`。
|
||||
- 收藏卡片已改为调用 `playDramaFromCatalog`,不再进入详情页。
|
||||
- 观看记录卡片已按 `progress.episodeId` 直达播放页对应集;episodeId 为空时从第 1 集起播。
|
||||
- 详情未加载完成的观看记录仍保持原禁用、加载和重试行为。
|
||||
- 已补收藏及观看记录直达 PlayerPage 的路由断言。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
01:09 +561: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 28.7s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,561 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98.2 修复 feed 更新触发 build 期通知
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98.2-feed-build-notify`。
|
||||
- PlayerPage 的 feed 更新改为帧后调度,同一帧多次变化继续由 PlayerLogic 合并。
|
||||
- PlayerLogic 在非 idle 调度阶段延后 feed 通知与 PageController 跳页,并给延迟通知去重。
|
||||
- HomeLogic.onDramaChanged 在 build/layout/paint 阶段改为帧后通知。
|
||||
- 已补 build 阶段 updateFeed、最终 PageController 位置及 HomeLogic 延后通知回归测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:55 +563: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 26.4s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,563 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-21 K98.3 修复频道切换复用已释放视频控制器
|
||||
|
||||
- 已从最新 `origin/develop` 创建 `fix/k98.3-disposed-video-controller`。
|
||||
- `PlaybackSession.active` 与 `preparedPlayerAt` 不再暴露 closed/retiring slot;
|
||||
`_retire` 先清空活动引用并通知,再等待界面帧边界后暂停和释放播放器。
|
||||
- `PlayerLogic._replaceSession` 切换 session 后等待 `endOfFrame`,并再次校验
|
||||
revision 后才关闭旧 session;其它 session 回收路径同样使用该帧边界。
|
||||
- 已补 retiring player 脱钩单测,以及真实 `PlayerVideoSurface` 经 feed 切换、
|
||||
连续 pump 后不出现 disposed controller 异常的回归测试。
|
||||
|
||||
门禁原始输出:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:58 +565: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 35.7s
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功;
|
||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||
|
||||
## 2026-10-22 登录异常改为屏幕居中弹窗
|
||||
|
||||
- 已从 `origin/develop`(`9b78e43`)创建 `feature/login-error-center-dialog`。
|
||||
- 新增 `lib/core/widgets/centered_error_dialog.dart`,统一居中样式、遮罩关闭、确认按钮。
|
||||
- 邮箱登录/注册与手机号 OTP/验证码异常改为居中弹窗;字段校验保持内联,成功提示不变。
|
||||
- 新增 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 三语言文案并重新生成 l10n。
|
||||
- 异常弹窗前提交 busy 已复位,避免 loading 卡在弹窗打开期间。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter gen-l10n
|
||||
Because l10n.yaml exists, the options defined there will be used instead.
|
||||
To use the command line arguments, delete the l10n.yaml file in the Flutter project.
|
||||
|
||||
$ flutter test --no-pub test/auth/auth_page_test.dart test/auth/phone_auth_sheet_test.dart
|
||||
00:02 +20: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:49 +570: All tests passed!
|
||||
```
|
||||
|
||||
解读:登录异常、注册标题、未知异常回退、本地校验不弹窗、遮罩/按钮关闭和手机号两条
|
||||
异常路径均通过;全量静态分析零告警,570 项测试全过。`pubspec.lock` 已还原,
|
||||
未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 登录异常调整为中心吐司
|
||||
|
||||
- 已从 `develop`(`11e37a9`)创建 `feature/login-error-center-toast`。
|
||||
- 删除 AlertDialog 组件,新增 OverlayEntry 居中吐司:淡入淡出、约 2 秒消失、不拦截手势。
|
||||
- 吐司保持全局单例,新消息替换旧消息;邮箱与手机号异常路径均已切换。
|
||||
- 本地表单校验继续内联,手机号验证码发送成功 SnackBar 保持不变。
|
||||
- 清理 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 并重新生成 l10n。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter gen-l10n
|
||||
Because l10n.yaml exists, the options defined there will be used instead.
|
||||
To use the command line arguments, delete the l10n.yaml file in the Flutter project.
|
||||
|
||||
$ flutter test --no-pub test/core/centered_toast_test.dart
|
||||
00:00 +3: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/auth/auth_page_test.dart test/auth/phone_auth_sheet_test.dart
|
||||
00:02 +20: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
01:00 +573: All tests passed!
|
||||
```
|
||||
|
||||
解读:吐司单例替换、点击穿透和自动消失通过;认证异常与本地校验路径通过;
|
||||
全量静态分析零告警,573 项测试全过。`pubspec.lock` 已还原,未启停服务,
|
||||
未操作真机。
|
||||
|
||||
## 2026-10-22 播放器 loading 与锁定进度条调整
|
||||
|
||||
- 已从 `develop`(`7b068e8`)创建 `feature/player-loading-polish`。
|
||||
- 播放器票据与 session loading 圈统一为 24×24、线宽 2。
|
||||
- loading 高光默认改为白色,普通轨道色与首页 white24 轨道保持不变。
|
||||
- 首页和全屏播放器进度条增加 locked 状态;锁定态显示静态 inactive 线,不进入 loading,
|
||||
禁用拖动与 seek。
|
||||
- 已补 loading 指示器尺寸、缓冲高光颜色、首页/全屏锁定态和锁定轨道高度测试。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/player/loading_progress_track_test.dart test/playback/seek_progress_bar_test.dart
|
||||
00:00 +8: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/home/home_preview_layout_test.dart
|
||||
00:01 +7: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/player/player_fullscreen_test.dart
|
||||
00:09 +37: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:50 +576: All tests passed!
|
||||
```
|
||||
|
||||
解读:加载高光、锁定静态轨道、拖动禁用和两个 loading 指示器尺寸均通过;
|
||||
全量静态分析零告警,576 项测试全过。`pubspec.lock` 已还原,未启停服务,
|
||||
未操作真机。
|
||||
|
||||
## 2026-10-22 屏幕常亮与前台自动续播
|
||||
|
||||
- 已从 `develop`(`5bf1022`)创建 `feature/keep-awake-auto-resume`。
|
||||
- Android 主 Activity 添加 `FLAG_KEEP_SCREEN_ON`,保留已有渠道、PiP 和键盘逻辑。
|
||||
- iOS AppDelegate 开启 `isIdleTimerDisabled`,前台保持常亮。
|
||||
- 用户主动暂停后回前台安全恢复;过期票据先完成重取再设置播放意图。
|
||||
- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景不会强制续播。
|
||||
- 后台仍立即保存进度并暂停,不做后台播放。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/player/player_logic_test.dart
|
||||
00:00 +35: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:50 +580: All tests passed!
|
||||
```
|
||||
|
||||
解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停
|
||||
均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原,
|
||||
未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 播放位置连续性
|
||||
|
||||
- 已从 `develop`(`fe49c6d`)创建 `feature/playback-position-continuity`。
|
||||
- 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId、position、duration、updatedAt。
|
||||
- App Provider 注入单一缓存实例,PlayerLogic 与详情页共享。
|
||||
- 进度监听、保存进度与进入详情前更新缓存;loadResumePoint 优先缓存,未命中再回源。
|
||||
- 详情页继续观看与 CTA 优先使用最近缓存集,返回首页保持当前播放器位置。
|
||||
- 跨剧缓存不命中;片尾保护继续复用 `clampResumePosition`。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/playback/playback_position_cache_test.dart
|
||||
00:00 +4: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/player/player_logic_test.dart
|
||||
00:00 +38: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/player/player_open_detail_test.dart
|
||||
00:00 +1: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:53 +589: All tests passed!
|
||||
```
|
||||
|
||||
解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集
|
||||
和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。
|
||||
`pubspec.lock` 已还原,未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 Seek 后播放位置保持
|
||||
|
||||
- 已从 `develop`(`47ca214`)创建 `feature/playback-seek-position`。
|
||||
- 新增播放位置 clamp;缓存、上报与详情返回统一使用 seek 完成后的 controller 位置。
|
||||
- PlayerLogic 增加 pending seek 屏障,saveCurrentProgress 与离开路径会等待 seek 和写入完成。
|
||||
- 监听器统一读取一次当前播放快照,避免旧位置覆盖新位置。
|
||||
- 已补 seek 目标缓存、越界 clamp、seek 后立即进详情并返回的位置测试。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/playback/playback_position_cache_test.dart
|
||||
00:00 +5: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/player/player_logic_test.dart --plain-name should_cache_actual_position_after_seek_completes
|
||||
00:00 +1: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/player/player_open_detail_test.dart
|
||||
00:00 +1: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:49 +591: All tests passed!
|
||||
```
|
||||
|
||||
解读:seek 完成后缓存与上报使用实际位置,越界目标被 clamp;seek 后立即
|
||||
进详情并返回仍保持目标位置。全量静态分析零告警,591 项测试全过。
|
||||
`pubspec.lock` 已还原,未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 播放入口无缓冲提示
|
||||
|
||||
- 已从 Seek 修复合并后的 `develop`(`f302b39`)创建 `feature/playback-entry-no-buffer`。
|
||||
- 未初始化进度条改为静态 inactive 轨道,不显示流动高光且不可拖动。
|
||||
- 仅 initialized 且 isBuffering 持续超过 350ms 时显示白色缓冲高光。
|
||||
- PlayerView 删除 session 居中 loading,只保留 ticket loading。
|
||||
- preparePlayer 改为初始化完成后先 seek 缓存位置再 play;详情返回复用原 controller。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/playback/seek_progress_bar_test.dart
|
||||
00:00 +7: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/home/home_preview_layout_test.dart
|
||||
00:01 +8: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/player/player_logic_test.dart
|
||||
00:00 +40: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/player/player_fullscreen_test.dart
|
||||
00:09 +37: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:49 +593: All tests passed!
|
||||
```
|
||||
|
||||
解读:短缓冲不闪、持续缓冲仍显示白色高光;未初始化/入口/返回/重建路径
|
||||
均无多余 loading,重建顺序为 initialize→seek→play。全量静态分析零告警,
|
||||
593 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 播放器进度拖动画面预览
|
||||
|
||||
- 已从最新 `develop`(`d06dce9`)创建 `feature/scrub-frame-preview`。
|
||||
- 计划文档:`docs/plans/2026-10-22-scrub-frame-preview.md`。
|
||||
- 性能红线:拖动期间主线程无卡顿;原生并发取帧不超过 1;预览图最大宽
|
||||
240px;Dart LRU 缓存不超过 24 帧;不新增 pub 依赖;不改后端契约。
|
||||
- 实施顺序:缩略图服务与缓存 -> 预览卡片及两处进度条 -> Android/iOS
|
||||
原生渠道 -> 单测/widget 测试与全量门禁。
|
||||
|
||||
```text
|
||||
$ git status --short --branch
|
||||
## feature/scrub-frame-preview
|
||||
|
||||
$ git log -5 --oneline --decorate
|
||||
d06dce9 (HEAD -> feature/scrub-frame-preview, origin/develop, develop) merge: 合并播放入口无缓冲提示
|
||||
```
|
||||
|
||||
解读:工作树从最新 develop 创建且开工前无未提交改动。当前仅新增计划与
|
||||
PROGRESS 留痕,后续每完成一个阶段会补充命令原始输出与解读。
|
||||
|
||||
### 实现结果
|
||||
|
||||
- 新增 `PlaybackThumbnailService`:1 秒 bucket、24 帧 LRU、单飞合流、
|
||||
最新 bucket 优先、停顿 120ms 后最多预取相邻 1 帧、1.5 秒统一回落。
|
||||
- 新增 `ScrubPreviewCard`:120dp 宽、圆角 8、白色 1px 描边,显示
|
||||
`当前时间 / 总时长`,安全边距内跟随拖动点;帧到达前始终显示封面。
|
||||
- 首页 feed 与全屏播放器进度条均接入;松手立即隐藏并取消尚未开始的请求。
|
||||
- Android 使用单线程 `MediaMetadataRetriever`,iOS 使用串行
|
||||
`AVAssetImageGenerator`;两端优先本地路径、最大宽 240px、JPEG 约 70、
|
||||
失败或 1.5 秒超时回调 null。
|
||||
- 未新增 pub 依赖,未改后端契约;`pubspec.lock` 已还原,未启停服务,
|
||||
未操作真机。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/playback/playback_thumbnail_service_test.dart test/playback/seek_progress_bar_test.dart test/home/home_preview_layout_test.dart
|
||||
00:01 +28: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:56 +606: All tests passed!
|
||||
```
|
||||
|
||||
解读:bucket、24 帧 LRU、同 bucket 合流、过期请求丢弃、松手取消、
|
||||
120ms 预取防覆盖、异常/超时回落、首页与全屏预览卡片、封面占位和松手立即
|
||||
消失均通过;全量静态分析零告警,606 项测试全过。性能红线保持:主线程不做
|
||||
图片解码/取帧、原生并发 1、预览最大宽 240px、缓存最多 24 帧、不增依赖、
|
||||
不改后端。原生取帧真实画面与设备性能由 PM 真机验收。
|
||||
|
||||
## 2026-10-22 雪碧图 VTT 拖动预览
|
||||
|
||||
- 已从 `develop`(`89a94f8`)创建 `feature/sprite-scrub-preview`。
|
||||
- 计划文档:`docs/plans/2026-10-22-sprite-scrub-preview.md`。
|
||||
- 性能红线:拖动帧不发起网络/解码,只做 cue 二分与 sourceRect 绘制;
|
||||
VTT 缓存 10 分钟且只解析一次;sprite ui.Image 最多 2 张;超过 2048px
|
||||
按比例缩放;签名地址不进入持久化缓存。
|
||||
- 回退链:雪碧图 -> 现有本地原生取帧 -> 集封面。
|
||||
|
||||
```text
|
||||
$ git status --short --branch
|
||||
## feature/sprite-scrub-preview
|
||||
|
||||
$ git rev-parse --short HEAD
|
||||
89a94f8
|
||||
```
|
||||
|
||||
解读:工作树从指定最新 develop 创建,开工前无未提交改动;主工作区现有
|
||||
`pubspec.lock` 未复制、未修改。
|
||||
|
||||
### 实现结果
|
||||
|
||||
- `Episode` / `PlayTicket` 增加 nullable `thumbnailsUrl`;票据写回和详情来源都保留,
|
||||
本地下载只覆盖视频地址,不丢 VTT;签名地址未进入持久化缓存。
|
||||
- 新增 `SpriteThumbnailAxisService`:Dio VTT 拉取、10 分钟缓存、403 清缓存重试一次、
|
||||
cue 解析与二分、sprite `ui.Image` LRU2、2048px 解码上限。
|
||||
- `PlayerPage` 在选中集就绪时预取当前及相邻 sprite;dispose 释放 VTT 与图片。
|
||||
- `ScrubPreviewCard` 优先用 `CustomPaint.drawImageRect` 绘制 `sourceRect`;
|
||||
VTT 未就绪显示封面,轴为空/加载失败回退本地原生取帧,再失败保留封面。
|
||||
- 拖动帧只做缓存查询、二分、矩形裁剪与 drawImageRect,不发起网络或图片解码。
|
||||
|
||||
执行命令与原始输出:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/playback/sprite_thumbnail_axis_test.dart test/player/scrub_preview_card_test.dart
|
||||
00:00 +11: All tests passed!
|
||||
|
||||
$ flutter test --no-pub test/playback/seek_progress_bar_test.dart test/home/home_preview_layout_test.dart test/player/player_fullscreen_test.dart
|
||||
00:10 +55: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:54 +617: All tests passed!
|
||||
```
|
||||
|
||||
解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、
|
||||
回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析
|
||||
零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 Android Studio 调试 HTTP 日志
|
||||
|
||||
- 分支:`feature/debug-http-logging`
|
||||
- 基线:`develop` / `origin/develop` = `dc4b602`
|
||||
- 计划:`docs/plans/2026-10-22-debug-http-logging.md`
|
||||
|
||||
```text
|
||||
$ git status --short --branch
|
||||
## develop...origin/develop
|
||||
M pubspec.lock
|
||||
|
||||
$ git rev-parse HEAD
|
||||
dc4b60270dd38d594510b45bd7a3d50b7dcd2602
|
||||
```
|
||||
|
||||
解读:基于最新 develop 创建功能分支;开工时 `pubspec.lock` 已有未提交改动,
|
||||
本次未修改、未暂存该文件。
|
||||
|
||||
### 实现结果
|
||||
|
||||
- 新增 `NetworkLogInterceptor`,默认 `debugPrint`,可注入
|
||||
`void Function(String)` 作为单测日志收集器。
|
||||
- `enabled` 常量与 `kDebugMode` 同时为真才输出,release/profile 零 HTTP 日志。
|
||||
- 请求行包含完整 URL 与 query,同时输出耗时起点、脱敏请求头和格式化请求体。
|
||||
- Authorization 仅保留前 8 字符后加 `...`;X-Device-Id 原样输出;
|
||||
其余请求头值统一 `***`。
|
||||
- ResponseType.bytes 响应统一 `utf8.decode(..., allowMalformed: true)`,
|
||||
JSON 自动缩进;图片/音视频/字体/PDF/ZIP 等只输出 MIME 与字节数。
|
||||
- 请求体和响应体超过 3000 字符时截断并追加 `…(truncated)`。
|
||||
- 拦截链为 `AuthHeaderInterceptor → EnvelopeInterceptor → NetworkLogInterceptor`。
|
||||
Envelope 在解包前暂存原始 bytes,末尾日志读取暂存值,因此仍能打印 Envelope
|
||||
处理前的原始响应。
|
||||
- 异常路径输出 `[HTTP] ✗ DioException类型 耗时ms URL message`,并继续
|
||||
`handler.next(err)`,不吞异常。
|
||||
|
||||
代表性原始日志:
|
||||
|
||||
```text
|
||||
[HTTP] → POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
|
||||
[HTTP] → 耗时起点:2026-10-22T12:12:25.377357
|
||||
[HTTP] → headers:{"Accept":"***","Authorization":"Bearer e...","X-Device-Id":"device-raw-001"}
|
||||
[HTTP] → body:{
|
||||
"filter": "中文"
|
||||
}
|
||||
[HTTP] ← 200 12ms POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
|
||||
[HTTP] ← body:{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"title": "中文标题"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/core/data/remote/network_log_interceptor_test.dart
|
||||
00:00 +0: should_log_request_and_response_lines_with_elapsed_when_requested
|
||||
00:00 +1: should_truncate_long_body_when_log_exceeds_limit
|
||||
00:00 +2: should_log_plain_text_without_throwing_when_response_is_not_json
|
||||
00:00 +3: should_log_byte_count_only_when_response_is_image
|
||||
00:00 +4: should_log_dio_exception_type_and_message_when_request_fails
|
||||
00:00 +5: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:58 +622: All tests passed!
|
||||
```
|
||||
|
||||
解读:请求/响应行与耗时、Authorization 脱敏、X-Device-Id 原样、bytes 中文
|
||||
UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 类型与 message
|
||||
均通过;全量静态分析零告警,622 项测试全过。
|
||||
|
||||
未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。
|
||||
|
||||
## 2026-10-22 收藏与观看历史分页 UI
|
||||
|
||||
- 分支:`feature/library-paging-ui`
|
||||
- 基线:`develop` / `origin/develop` = `63d3b0f`
|
||||
- 后端依赖:FruitServer `7b69493`(收藏/观看历史 PageResult 与展示字段)
|
||||
- 计划:`docs/plans/2026-10-22-library-paging-ui.md`
|
||||
|
||||
### 实现
|
||||
|
||||
- 新增通用 `PageResult<T>`;`LibraryApi.favorites` 与 `WatchApi.recent`
|
||||
统一请求 `pageIndex/pageSize` 并返回 `PageResult`。
|
||||
- 收藏与观看仓储、Controller 增加首屏、刷新、loadMore、total、hasMore、
|
||||
loadingMore 和 loadMoreError;请求代次避免旧刷新/旧分页覆盖新列表。
|
||||
- 收藏按 dramaId 去重,观看按 dramaId 去重;空页或 `loaded >= total` 停止请求。
|
||||
- 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。
|
||||
- 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`;
|
||||
标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。
|
||||
- API 严格解析服务端 PageResult;旧 List 不做客户端全量包装,由后端部署新契约解决。
|
||||
- MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。
|
||||
|
||||
### 门禁
|
||||
|
||||
定向测试:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/library/library_api_test.dart test/library/favorite_controller_test.dart test/library/favorites_page_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart test/mine/mine_logic_test.dart
|
||||
00:03 +40: All tests passed!
|
||||
```
|
||||
|
||||
完整门禁:
|
||||
|
||||
```text
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:56 +632: All tests passed!
|
||||
```
|
||||
|
||||
解读:分页第一页、追加去重、无更多不重复请求、loadMore 失败重试、
|
||||
观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。
|
||||
|
||||
未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。
|
||||
|
||||
### 范围修订:观看标题优先与按需详情
|
||||
|
||||
- 分页协议、Repository 与 Controller 分页状态保持不变。
|
||||
- `_hydrate` 只对 `dramaTitle` 为空的记录补拉详情;后端已有标题时不再请求详情。
|
||||
- 有标题的记录点击播放时才调用 `detailForOpen` 按需加载详情,避免列表阶段 N+1。
|
||||
- 标题仍为空时使用本地化“第 N 集”,封面缺失继续显示占位。
|
||||
- 移除了旧 List 自动包装为 PageResult 的前端全量兜底,避免掩盖后端契约问题。
|
||||
|
||||
真实后端分页取证(仅测试账号写入,不启停服务):
|
||||
|
||||
```text
|
||||
GET /app/watch/recent?pageIndex=1&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/watch/recent?pageIndex=2&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/favorites?pageIndex=1&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/favorites?pageIndex=2&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
```
|
||||
|
||||
解读:当前 8081 运行实例仍是旧包,返回 List 且忽略 pageIndex/pageSize;第 1/2 页
|
||||
均返回相同两条数据。客户端不做全量兜底;live smoke 检测到旧 List 后明确标记 skipped,
|
||||
等待后端新包部署后再执行分页真实联调。
|
||||
|
||||
门禁:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/integration/live_watch_flow_test.dart test/library/library_api_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart
|
||||
00:02 +22 ~1: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:52 +630 ~1: All tests passed!
|
||||
```
|
||||
|
||||
解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、
|
||||
第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务
|
||||
能力探测跳过。`pubspec.lock` 既有改动未提交。
|
||||
|
||||
## 2026-10-22 收藏卡片简介
|
||||
|
||||
- 分支:`feature/favorite-card-synopsis`
|
||||
- 基线:`develop` / `origin/develop` = `9093700`
|
||||
- 计划:`docs/plans/2026-10-22-favorite-card-synopsis.md`
|
||||
|
||||
### 实现
|
||||
|
||||
- `_FavoriteCard` 在标题/标签下方条件渲染 `drama.synopsis.trim()`。
|
||||
- 简介最多 2 行、`TextOverflow.ellipsis`,使用 `AppColors.muted`、12 号字和
|
||||
`1.15` 行高,由 `Flexible` 占据剩余空间,保持封面与卡片原高度。
|
||||
- 简介为空或纯空白时不创建文本节点,继续使用原 Spacer,不占高度。
|
||||
- 未改接口、后端和收藏分页逻辑。
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/library/favorites_page_test.dart
|
||||
00:01 +7: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:51 +631 ~1: All tests passed!
|
||||
```
|
||||
|
||||
解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致;
|
||||
全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。
|
||||
`pubspec.lock` 既有改动未提交。
|
||||
@@ -1,5 +1,24 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
private final class ThumbnailResponseGate {
|
||||
private let lock = NSLock()
|
||||
private var completed = false
|
||||
|
||||
func send(_ result: @escaping FlutterResult, data: Data?) {
|
||||
lock.lock()
|
||||
if completed {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
completed = true
|
||||
lock.unlock()
|
||||
DispatchQueue.main.async {
|
||||
result(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the system LaunchScreen as the only splash. Flutter would otherwise
|
||||
/// instantiate that storyboard again and the icon appears a second time.
|
||||
@@ -16,10 +35,16 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private let thumbnailQueue = DispatchQueue(
|
||||
label: "com.stormg.lumadrama.thumbnail",
|
||||
qos: .userInitiated
|
||||
)
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
@@ -43,5 +68,66 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
|
||||
controller.present(activity, animated: true)
|
||||
}
|
||||
|
||||
let thumbnailChannel = FlutterMethodChannel(
|
||||
name: "com.stormg.lumadrama/thumbnail",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
thumbnailChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard call.method == "frame" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
guard let self,
|
||||
let arguments = call.arguments as? [String: Any],
|
||||
let timeNumber = arguments["timeMs"] as? NSNumber else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
let localPath = arguments["localPath"] as? String ?? ""
|
||||
let remoteUrl = arguments["url"] as? String ?? ""
|
||||
let headers = arguments["headers"] as? [String: String] ?? [:]
|
||||
let assetUrl: URL?
|
||||
if !localPath.isEmpty {
|
||||
assetUrl = localPath.hasPrefix("file:")
|
||||
? URL(string: localPath)
|
||||
: URL(fileURLWithPath: localPath)
|
||||
} else if !remoteUrl.isEmpty {
|
||||
assetUrl = URL(string: remoteUrl)
|
||||
} else {
|
||||
assetUrl = nil
|
||||
}
|
||||
guard let assetUrl else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let options: [String: Any]? = headers.isEmpty
|
||||
? nil
|
||||
: [AVURLAssetHTTPHeaderFieldsKey: headers]
|
||||
let asset = AVURLAsset(url: assetUrl, options: options)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: 240, height: 426)
|
||||
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||
let time = CMTime(value: timeNumber.int64Value, timescale: 1000)
|
||||
let gate = ThumbnailResponseGate()
|
||||
|
||||
self.thumbnailQueue.async {
|
||||
do {
|
||||
let image = try generator.copyCGImage(at: time, actualTime: nil)
|
||||
let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.7)
|
||||
gate.send(result, data: data)
|
||||
} catch {
|
||||
gate.send(result, data: nil)
|
||||
}
|
||||
}
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||
deadline: .now() + 1.5
|
||||
) {
|
||||
gate.send(result, data: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ import 'core/data/services/download_controller.dart';
|
||||
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';
|
||||
@@ -71,6 +73,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
this.watchHistoryRepository,
|
||||
this.favoriteRepository,
|
||||
this.playbackRepository,
|
||||
this.playbackPositionCache,
|
||||
this.commentRepository,
|
||||
this.interactionRepository,
|
||||
this.noticeRepository,
|
||||
@@ -90,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;
|
||||
@@ -145,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,
|
||||
);
|
||||
@@ -235,9 +242,11 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider(create: (_) => PlaybackCoordinator()),
|
||||
Provider<EventTracker>(create: (_) => EventTracker(_apiClient)),
|
||||
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),
|
||||
|
||||
@@ -11,10 +11,9 @@ import 'package:flutter/foundation.dart';
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
/// 真机默认使用的开发机局域网地址。
|
||||
/// 开发调试默认服务地址,当前指向本机局域网 IP。
|
||||
///
|
||||
/// 这个地址跟着电脑所在的网络走,换网络后要用
|
||||
/// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。
|
||||
/// 换网络时只改这一行,地址不要带结尾斜杠。
|
||||
static const String devHostBaseUrl = 'http://192.168.31.187:8081';
|
||||
|
||||
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
|
||||
@@ -38,7 +37,7 @@ class AppConfig {
|
||||
static const List<String> _probeCandidates = [
|
||||
lanBaseUrl,
|
||||
devHostBaseUrl,
|
||||
publicBaseUrl,
|
||||
if (devHostBaseUrl != publicBaseUrl) publicBaseUrl,
|
||||
];
|
||||
|
||||
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`.
|
||||
@@ -85,7 +84,15 @@ class AppConfig {
|
||||
static Future<void> probeAndApplyBaseUrl({
|
||||
Future<bool> Function(String baseUrl)? probe,
|
||||
String? fallbackBaseUrl,
|
||||
|
||||
/// 编译期地址测试注入;未传时读取 `API_BASE_URL`。
|
||||
String? compiledOverride,
|
||||
}) async {
|
||||
final compiled = compiledOverride ?? compiledBaseUrl;
|
||||
if (compiled.isNotEmpty) {
|
||||
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(compiled);
|
||||
return;
|
||||
}
|
||||
final candidates = <String>[
|
||||
if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl,
|
||||
for (final candidate in _probeCandidates)
|
||||
|
||||
@@ -15,6 +15,9 @@ class Episode {
|
||||
this.videoUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.locked = false,
|
||||
this.likeCount,
|
||||
this.commentCount,
|
||||
this.thumbnailsUrl,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -28,8 +31,16 @@ class Episode {
|
||||
|
||||
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
|
||||
final bool locked;
|
||||
final int? likeCount;
|
||||
final int? commentCount;
|
||||
final String? thumbnailsUrl;
|
||||
|
||||
Episode withVideoUrl(String url) => Episode(
|
||||
Episode withVideoUrl(
|
||||
String url, {
|
||||
int? likeCount,
|
||||
int? commentCount,
|
||||
String? thumbnailsUrl,
|
||||
}) => Episode(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
@@ -37,6 +48,9 @@ class Episode {
|
||||
videoUrl: url,
|
||||
durationSec: durationSec,
|
||||
locked: locked,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
commentCount: commentCount ?? this.commentCount,
|
||||
thumbnailsUrl: thumbnailsUrl ?? this.thumbnailsUrl,
|
||||
);
|
||||
|
||||
factory Episode.fromJson(Map<String, dynamic> json) {
|
||||
@@ -48,6 +62,9 @@ class Episode {
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
locked: json['locked'] == true,
|
||||
videoUrl: json['playUrl'] as String? ?? '',
|
||||
likeCount: _optionalInt(json['likeCount']),
|
||||
commentCount: _optionalInt(json['commentCount']),
|
||||
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -150,3 +167,8 @@ class Drama {
|
||||
}
|
||||
|
||||
int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value);
|
||||
|
||||
String? _optionalString(dynamic value) {
|
||||
final text = value is String ? value.trim() : '';
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'drama.dart';
|
||||
|
||||
/// `/app/feed` 的游标分页数据。
|
||||
class FeedPage {
|
||||
const FeedPage({
|
||||
this.dramas = const [],
|
||||
this.nextCursor,
|
||||
this.hasMore = false,
|
||||
});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final String? nextCursor;
|
||||
final bool hasMore;
|
||||
|
||||
factory FeedPage.fromJson(Map<String, dynamic> json) {
|
||||
final rawCursor = json['nextCursor'];
|
||||
final cursor = rawCursor?.toString();
|
||||
return FeedPage(
|
||||
dramas: (json['list'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(),
|
||||
nextCursor: cursor == null || cursor.isEmpty || cursor == 'null'
|
||||
? null
|
||||
: cursor,
|
||||
hasMore: json['hasMore'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 互动目标类型,契约只允许短剧与评论。
|
||||
/// 互动目标类型。
|
||||
enum InteractionTargetType {
|
||||
drama('DRAMA'),
|
||||
episode('EPISODE'),
|
||||
comment('COMMENT');
|
||||
|
||||
const InteractionTargetType(this.apiValue);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 服务端标准分页响应 `{total, list}`。
|
||||
///
|
||||
/// 兼容旧版「data 直接是数组」的响应:按数组长度作为 total,避免列表空白。
|
||||
class PageResult<T> {
|
||||
const PageResult({this.total = 0, this.list = const []});
|
||||
|
||||
final int total;
|
||||
final List<T> list;
|
||||
|
||||
/// 解析分页壳,条目由调用方提供具体解析函数。
|
||||
factory PageResult.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) parseItem,
|
||||
) {
|
||||
final items = _parseItemMaps(json['list']).map(parseItem).toList(
|
||||
growable: false,
|
||||
);
|
||||
return PageResult(total: jsonToInt(json['total']), list: items);
|
||||
}
|
||||
|
||||
/// 兼容分页 Map 与旧版纯 List;其它类型视为空页。
|
||||
factory PageResult.parse(
|
||||
Object? data,
|
||||
T Function(Map<String, dynamic>) parseItem,
|
||||
) {
|
||||
if (data is Map) {
|
||||
return PageResult.fromJson(Map<String, dynamic>.from(data), parseItem);
|
||||
}
|
||||
if (data is List) {
|
||||
final items = _parseItemMaps(data).map(parseItem).toList(growable: false);
|
||||
return PageResult(total: items.length, list: items);
|
||||
}
|
||||
return PageResult<T>();
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _parseItemMaps(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [
|
||||
for (final item in raw)
|
||||
if (item is Map) Map<String, dynamic>.from(item),
|
||||
];
|
||||
}
|
||||
@@ -14,6 +14,9 @@ class PlayTicket {
|
||||
this.unlockCoin = 0,
|
||||
this.previewSeconds = 0,
|
||||
this.qualities = const [],
|
||||
this.thumbnailsUrl,
|
||||
this.likeCount,
|
||||
this.commentCount,
|
||||
});
|
||||
|
||||
static const String free = 'FREE';
|
||||
@@ -38,6 +41,9 @@ class PlayTicket {
|
||||
|
||||
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
|
||||
final List<String> qualities;
|
||||
final String? thumbnailsUrl;
|
||||
final int? likeCount;
|
||||
final int? commentCount;
|
||||
|
||||
bool get needsUnlock =>
|
||||
accessType == coinRequired || accessType == vipRequired;
|
||||
@@ -58,6 +64,18 @@ class PlayTicket {
|
||||
.whereType<String>()
|
||||
.where((quality) => quality.isNotEmpty)
|
||||
.toList(),
|
||||
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
|
||||
likeCount: json['likeCount'] == null
|
||||
? null
|
||||
: jsonToInt(json['likeCount']),
|
||||
commentCount: json['commentCount'] == null
|
||||
? null
|
||||
: jsonToInt(json['commentCount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _optionalString(dynamic value) {
|
||||
final text = value is String ? value.trim() : '';
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
@@ -12,6 +12,10 @@ class WatchProgress {
|
||||
required this.episodeId,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
this.dramaTitle,
|
||||
this.dramaCover,
|
||||
this.episodeNumber,
|
||||
this.totalEpisodes,
|
||||
this.finished = false,
|
||||
this.playCount = 0,
|
||||
this.updatedAt,
|
||||
@@ -19,6 +23,10 @@ class WatchProgress {
|
||||
|
||||
final String dramaId;
|
||||
final String episodeId;
|
||||
final String? dramaTitle;
|
||||
final String? dramaCover;
|
||||
final int? episodeNumber;
|
||||
final int? totalEpisodes;
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final bool finished;
|
||||
@@ -37,6 +45,14 @@ class WatchProgress {
|
||||
return WatchProgress(
|
||||
dramaId: (json['dramaId'] ?? '').toString(),
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
dramaTitle: json['dramaTitle']?.toString(),
|
||||
dramaCover: json['dramaCover']?.toString(),
|
||||
episodeNumber: json['episodeNumber'] == null
|
||||
? null
|
||||
: jsonToInt(json['episodeNumber']),
|
||||
totalEpisodes: json['totalEpisodes'] == null
|
||||
? null
|
||||
: jsonToInt(json['totalEpisodes']),
|
||||
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
|
||||
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
|
||||
finished: json['finished'] == true,
|
||||
@@ -48,6 +64,10 @@ class WatchProgress {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'dramaId': dramaId,
|
||||
'episodeId': episodeId,
|
||||
if (dramaTitle != null) 'dramaTitle': dramaTitle,
|
||||
if (dramaCover != null) 'dramaCover': dramaCover,
|
||||
if (episodeNumber != null) 'episodeNumber': episodeNumber,
|
||||
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
|
||||
'positionMs': position.inMilliseconds,
|
||||
'durationMs': duration.inMilliseconds,
|
||||
'finished': finished,
|
||||
|
||||
@@ -19,8 +19,10 @@ class WatchRecord {
|
||||
|
||||
String get dramaId => progress.dramaId;
|
||||
|
||||
/// 详情中按 episodeId 定位集号;缺失时返回 0。
|
||||
/// 优先使用分页接口集号,其次从已加载详情定位。
|
||||
int get episodeNumber {
|
||||
final pageEpisode = progress.episodeNumber;
|
||||
if (pageEpisode != null && pageEpisode > 0) return pageEpisode;
|
||||
final episodes = drama?.episodes ?? const <Episode>[];
|
||||
for (var index = 0; index < episodes.length; index++) {
|
||||
if (episodes[index].id == progress.episodeId) return index + 1;
|
||||
@@ -28,6 +30,24 @@ class WatchRecord {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 服务端分页标题优先,详情标题作为回退。
|
||||
String get displayTitle {
|
||||
final pageTitle = progress.dramaTitle?.trim() ?? '';
|
||||
return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? '';
|
||||
}
|
||||
|
||||
/// 服务端分页封面优先,详情封面作为回退。
|
||||
String get displayCover {
|
||||
final pageCover = progress.dramaCover?.trim() ?? '';
|
||||
return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? '';
|
||||
}
|
||||
|
||||
/// 分页接口已带展示字段时无需等待详情加载。
|
||||
bool get hasDisplayMetadata =>
|
||||
displayTitle.isNotEmpty ||
|
||||
progress.dramaCover?.trim().isNotEmpty == true ||
|
||||
(progress.episodeNumber ?? 0) > 0;
|
||||
|
||||
/// 生成卡片加载中、成功或失败后的新状态。
|
||||
WatchRecord copyWith({
|
||||
Drama? drama,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'api_interceptors.dart';
|
||||
import 'network_log_interceptor.dart';
|
||||
|
||||
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
|
||||
export 'api_exception.dart';
|
||||
@@ -39,6 +42,7 @@ class ApiClient {
|
||||
deviceIdReader: deviceIdReader,
|
||||
),
|
||||
const EnvelopeInterceptor(),
|
||||
NetworkLogInterceptor(),
|
||||
]);
|
||||
if (adapter != null) {
|
||||
_dio.httpClientAdapter = adapter;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import 'network_log_interceptor.dart';
|
||||
|
||||
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
|
||||
class ApiRequestFlags {
|
||||
@@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor {
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
final options = response.requestOptions;
|
||||
if (NetworkLogInterceptor.isEnabled) {
|
||||
options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] =
|
||||
response.data;
|
||||
}
|
||||
final shell = _decodeShell(response.data);
|
||||
final code = shell['code'] as int? ?? -1;
|
||||
final message = shell['message'] as String?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/feed_page.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/search_result.dart';
|
||||
import 'api_client.dart';
|
||||
@@ -17,6 +18,29 @@ class CatalogApi {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||
}
|
||||
|
||||
/// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。
|
||||
Future<FeedPage> feed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async {
|
||||
return FeedPage.fromJson(
|
||||
_asMap(
|
||||
await _client.get(
|
||||
'/app/feed',
|
||||
query: {
|
||||
if (types.isNotEmpty) 'types': types.join(','),
|
||||
if (categoryId != null && categoryId.isNotEmpty)
|
||||
'categoryId': categoryId,
|
||||
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
||||
'size': '$size',
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
|
||||
Future<SearchResult> search(
|
||||
String keyword, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/comment.dart';
|
||||
import '../models/json_numbers.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
|
||||
@@ -12,6 +13,7 @@ class CommentApi {
|
||||
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
@@ -20,6 +22,7 @@ class CommentApi {
|
||||
'/app/comments',
|
||||
query: {
|
||||
'dramaId': dramaId,
|
||||
'episodeId': ?episodeId,
|
||||
'rootId': ?rootId,
|
||||
'cursor': ?cursor,
|
||||
'size': '$size',
|
||||
@@ -29,6 +32,15 @@ class CommentApi {
|
||||
return CommentPage.fromJson(data);
|
||||
}
|
||||
|
||||
/// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。
|
||||
Future<int> count(String dramaId, {String? episodeId}) async {
|
||||
final data = await _client.get(
|
||||
'/app/comments/count',
|
||||
query: {'dramaId': dramaId, 'episodeId': ?episodeId},
|
||||
);
|
||||
return jsonToInt(data);
|
||||
}
|
||||
|
||||
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
|
||||
Future<DramaComment> publish({
|
||||
required String dramaId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/page_result.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
|
||||
@@ -20,13 +21,16 @@ class LibraryApi {
|
||||
return data == true;
|
||||
}
|
||||
|
||||
Future<List<Drama>> favorites() async {
|
||||
final data = await _client.get('/app/favorites');
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
|
||||
];
|
||||
Future<PageResult<Drama>> favorites({
|
||||
int pageIndex = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
final data = await _client.get(
|
||||
'/app/favorites',
|
||||
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
|
||||
);
|
||||
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
|
||||
return PageResult.parse(data, Drama.fromCardJson);
|
||||
}
|
||||
|
||||
Future<void> removeFavorite(String dramaId) async {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 调试构建下输出 Dio 请求、响应和异常详情。
|
||||
class NetworkLogInterceptor extends Interceptor {
|
||||
/// 在调试时关闭日志的本地常量开关。
|
||||
static const bool enabled = true;
|
||||
|
||||
/// Envelope 解包前暂存原始响应体,供链尾日志读取。
|
||||
static const String rawResponseDataExtraKey = 'app.http.rawResponseData';
|
||||
|
||||
/// 请求、响应体单条日志最大字符数。
|
||||
static const int maxBodyLength = 3000;
|
||||
|
||||
static const String _truncatedSuffix = '…(truncated)';
|
||||
static const String _stopwatchKey = 'app.http.stopwatch';
|
||||
static const String _startedAtKey = 'app.http.startedAt';
|
||||
static const String _emptyBody = '<empty>';
|
||||
static const String _redacted = '***';
|
||||
|
||||
/// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。
|
||||
NetworkLogInterceptor({void Function(String)? logger})
|
||||
: _logger = logger ?? debugPrint;
|
||||
|
||||
final void Function(String) _logger;
|
||||
|
||||
/// release/profile 下恒为 false,保证零 HTTP 日志输出。
|
||||
static bool get isEnabled => kDebugMode && enabled;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(options);
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
options.extra[_stopwatchKey] = stopwatch;
|
||||
options.extra[_startedAtKey] = DateTime.now().toIso8601String();
|
||||
final body = _formatRequestBody(options.data);
|
||||
final message = StringBuffer()
|
||||
..writeln('[HTTP] → ${options.method} ${options.uri}')
|
||||
..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}')
|
||||
..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}')
|
||||
..writeln('[HTTP] → body:$body');
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(response);
|
||||
}
|
||||
final options = response.requestOptions;
|
||||
final elapsed = _elapsedMilliseconds(options);
|
||||
final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data;
|
||||
final body = _formatResponseBody(
|
||||
rawBody,
|
||||
response.headers.value(Headers.contentTypeHeader),
|
||||
);
|
||||
final message = StringBuffer()
|
||||
..writeln(
|
||||
'[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms '
|
||||
'${options.method} ${options.uri}',
|
||||
)
|
||||
..writeln('[HTTP] ← body:$body');
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(err);
|
||||
}
|
||||
final options = err.requestOptions;
|
||||
final message = StringBuffer()
|
||||
..writeln(
|
||||
'[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms '
|
||||
'${options.uri} ${err.message ?? ''}',
|
||||
)
|
||||
..write(_errorBodyLine(err));
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
int _elapsedMilliseconds(RequestOptions options) {
|
||||
final stopwatch = options.extra[_stopwatchKey];
|
||||
if (stopwatch is Stopwatch) {
|
||||
return stopwatch.elapsedMilliseconds;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
String _errorBodyLine(DioException error) {
|
||||
final rawBody =
|
||||
error.requestOptions.extra[rawResponseDataExtraKey] ??
|
||||
error.response?.data;
|
||||
if (rawBody == null) {
|
||||
return '';
|
||||
}
|
||||
final body = _formatResponseBody(
|
||||
rawBody,
|
||||
error.response?.headers.value(Headers.contentTypeHeader),
|
||||
);
|
||||
return '\n[HTTP] ✗ body:$body';
|
||||
}
|
||||
|
||||
String _formatHeaders(Map<String, dynamic> headers) {
|
||||
if (headers.isEmpty) {
|
||||
return '{}';
|
||||
}
|
||||
final entries = headers.entries.toList()
|
||||
..sort((left, right) => left.key.compareTo(right.key));
|
||||
final safe = <String, String>{
|
||||
for (final entry in entries)
|
||||
entry.key: _safeHeaderValue(entry.key, entry.value),
|
||||
};
|
||||
return _truncate(jsonEncode(safe));
|
||||
}
|
||||
|
||||
String _safeHeaderValue(String name, Object? value) {
|
||||
final text = value?.toString() ?? '';
|
||||
if (name.toLowerCase() == 'authorization') {
|
||||
return _maskAuthorization(text);
|
||||
}
|
||||
if (name.toLowerCase() == 'x-device-id') {
|
||||
return text;
|
||||
}
|
||||
return _redacted;
|
||||
}
|
||||
|
||||
String _maskAuthorization(String value) {
|
||||
if (value.length <= 8) {
|
||||
return _redacted;
|
||||
}
|
||||
return '${value.substring(0, math.min(8, value.length))}...';
|
||||
}
|
||||
|
||||
String _formatRequestBody(Object? body) {
|
||||
if (body == null) {
|
||||
return _emptyBody;
|
||||
}
|
||||
final json = _tryPrettyJson(body);
|
||||
if (json != null) {
|
||||
return _truncate(json);
|
||||
}
|
||||
final length = switch (body) {
|
||||
FormData data => data.length,
|
||||
List<int> bytes => bytes.length,
|
||||
_ => utf8.encode(body.toString()).length,
|
||||
};
|
||||
return '${body.runtimeType} ($length bytes)';
|
||||
}
|
||||
|
||||
String _formatResponseBody(Object? body, String? contentType) {
|
||||
if (body == null) {
|
||||
return _emptyBody;
|
||||
}
|
||||
if (body is List<int>) {
|
||||
final bytes = body is Uint8List ? body : Uint8List.fromList(body);
|
||||
if (_isBinary(contentType)) {
|
||||
return '<binary ${contentType ?? 'unknown'}, ${bytes.length} bytes>';
|
||||
}
|
||||
final text = utf8.decode(bytes, allowMalformed: true);
|
||||
return _formatText(text);
|
||||
}
|
||||
return _formatText(body.toString());
|
||||
}
|
||||
|
||||
String _formatText(String text) {
|
||||
if (text.isEmpty) {
|
||||
return _emptyBody;
|
||||
}
|
||||
return _truncate(_tryPrettyJson(text) ?? text);
|
||||
}
|
||||
|
||||
String? _tryPrettyJson(Object? body) {
|
||||
try {
|
||||
final value = body is String
|
||||
? jsonDecode(body)
|
||||
: body is List<int>
|
||||
? jsonDecode(utf8.decode(body, allowMalformed: true))
|
||||
: body;
|
||||
if (value is! Map && value is! List) {
|
||||
return null;
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isBinary(String? contentType) {
|
||||
final type = contentType?.toLowerCase() ?? '';
|
||||
return type.startsWith('image/') ||
|
||||
type.startsWith('audio/') ||
|
||||
type.startsWith('video/') ||
|
||||
type.startsWith('font/') ||
|
||||
type.contains('application/octet-stream') ||
|
||||
type.contains('application/pdf') ||
|
||||
type.contains('application/zip');
|
||||
}
|
||||
|
||||
String _truncate(String value) {
|
||||
if (value.length <= maxBodyLength) {
|
||||
return value;
|
||||
}
|
||||
final end = maxBodyLength - _truncatedSuffix.length;
|
||||
return '${value.substring(0, end)}$_truncatedSuffix';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/page_result.dart';
|
||||
import '../models/watch_progress.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
@@ -39,17 +40,17 @@ class WatchApi {
|
||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
||||
}
|
||||
|
||||
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
/// 最近观看分页(服务端已按更新时间倒序)。
|
||||
Future<PageResult<WatchProgress>> recent({
|
||||
int pageIndex = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
final data = await _client.get(
|
||||
'/app/watch/recent',
|
||||
query: {'limit': '$limit'},
|
||||
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
|
||||
);
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
|
||||
];
|
||||
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
|
||||
return PageResult.parse(data, WatchProgress.fromJson);
|
||||
}
|
||||
|
||||
/// 删除某部剧的观看记录(记录页清单条用)。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/feed_page.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
@@ -13,6 +14,13 @@ abstract interface class CatalogRepository {
|
||||
|
||||
Future<HomeFeed> fetchDiscoverFeed();
|
||||
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
});
|
||||
|
||||
Future<Drama> fetchDrama(String dramaId);
|
||||
}
|
||||
|
||||
@@ -28,6 +36,18 @@ class HttpCatalogRepository implements CatalogRepository {
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) => _api.feed(
|
||||
types: types,
|
||||
categoryId: categoryId,
|
||||
cursor: cursor,
|
||||
size: size,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
}
|
||||
@@ -8,11 +8,15 @@ abstract interface class CommentRepository {
|
||||
/// 拉取评论或回复的分页。
|
||||
Future<CommentPage> page({
|
||||
required String dramaId,
|
||||
String? episodeId,
|
||||
String? rootId,
|
||||
String? cursor,
|
||||
int size = commentPageSize,
|
||||
});
|
||||
|
||||
/// 查询单部剧的有效评论总数。
|
||||
Future<int> count(String dramaId, {String? episodeId});
|
||||
|
||||
/// 发表评论或回复。
|
||||
Future<DramaComment> publish({
|
||||
required String dramaId,
|
||||
@@ -34,16 +38,22 @@ 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, {String? episodeId}) =>
|
||||
_api.count(dramaId, episodeId: episodeId);
|
||||
|
||||
@override
|
||||
Future<DramaComment> publish({
|
||||
required String dramaId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 核心埋点事件名(第一阶段口径,后续与运营对齐后可扩展)。
|
||||
class AppEvents {
|
||||
AppEvents._();
|
||||
|
||||
static const String playbackStart = 'playback_start';
|
||||
static const String playbackEnd = 'playback_end';
|
||||
static const String paySuccess = 'pay_success';
|
||||
static const String register = 'register';
|
||||
static const String click = 'click';
|
||||
}
|
||||
|
||||
/// 埋点上报:本地缓冲、批量上报,失败静默丢弃不影响主流程。
|
||||
class EventTracker {
|
||||
EventTracker(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const int _flushSize = 10;
|
||||
static const Duration _flushInterval = Duration(seconds: 10);
|
||||
|
||||
final List<Map<String, Object?>> _buffer = [];
|
||||
Timer? _timer;
|
||||
|
||||
void track(
|
||||
String eventName, {
|
||||
String? page,
|
||||
Map<String, Object?>? properties,
|
||||
}) {
|
||||
_buffer.add({
|
||||
'eventName': eventName,
|
||||
'eventTime': DateTime.now().toIso8601String(),
|
||||
'page': page,
|
||||
'properties': properties,
|
||||
});
|
||||
if (_buffer.length >= _flushSize) {
|
||||
flush();
|
||||
} else {
|
||||
_timer ??= Timer(_flushInterval, flush);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> flush() async {
|
||||
if (_buffer.isEmpty) return;
|
||||
final events = List<Map<String, Object?>>.from(_buffer);
|
||||
_buffer.clear();
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
try {
|
||||
await _api.post('/app/events', body: {
|
||||
'deviceId': 'device-0001',
|
||||
'sessionId': 'session-0001',
|
||||
'events': events,
|
||||
});
|
||||
} catch (_) {
|
||||
// 埋点失败不影响主流程,静默丢弃。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart';
|
||||
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
||||
/// "点了没反应"或"看起来收藏了其实没有"。
|
||||
class FavoriteController extends ChangeNotifier {
|
||||
FavoriteController(this._repository);
|
||||
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
|
||||
|
||||
static const int defaultPageSize = 20;
|
||||
|
||||
final FavoriteRepository _repository;
|
||||
final int pageSize;
|
||||
final Set<String> _saved = {};
|
||||
List<Drama> cards = const [];
|
||||
int total = 0;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
bool hasMore = false;
|
||||
String? error;
|
||||
String? loadMoreError;
|
||||
int _pageIndex = 0;
|
||||
int _requestSeq = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
Set<String> get savedIds => Set.unmodifiable(_saved);
|
||||
@@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier {
|
||||
|
||||
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
|
||||
Future<void> load() async {
|
||||
final request = ++_requestSeq;
|
||||
loading = true;
|
||||
loadingMore = false;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
notify();
|
||||
try {
|
||||
final list = await _repository.list();
|
||||
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
|
||||
if (_disposed || request != _requestSeq) return;
|
||||
final list = _uniqueCards(page.list);
|
||||
cards = list;
|
||||
_saved
|
||||
..clear()
|
||||
..addAll(list.map((drama) => drama.id));
|
||||
total = page.total;
|
||||
_pageIndex = 1;
|
||||
hasMore = page.list.isNotEmpty && cards.length < total;
|
||||
} catch (_) {
|
||||
error = 'favoritesError';
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
error = 'favoritesError';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
notify();
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加下一页;正在刷新、已在加载或无更多时直接忽略。
|
||||
Future<void> loadMore() async {
|
||||
if (loading || loadingMore || !hasMore) return;
|
||||
final request = ++_requestSeq;
|
||||
loadingMore = true;
|
||||
loadMoreError = null;
|
||||
notify();
|
||||
try {
|
||||
final page = await _repository.page(
|
||||
pageIndex: _pageIndex + 1,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
if (_disposed || request != _requestSeq) return;
|
||||
final known = cards.map((drama) => drama.id).toSet();
|
||||
final appended = _uniqueCards(
|
||||
page.list,
|
||||
).where((drama) => known.add(drama.id)).toList(growable: false);
|
||||
cards = [...cards, ...appended];
|
||||
_saved.addAll(appended.map((drama) => drama.id));
|
||||
total = page.total;
|
||||
_pageIndex += 1;
|
||||
hasMore = page.list.isNotEmpty && cards.length < total;
|
||||
} catch (_) {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loadMoreError = 'favoritesError';
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loadingMore = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +107,17 @@ class FavoriteController extends ChangeNotifier {
|
||||
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
|
||||
Future<bool> toggle(String dramaId, {Drama? card}) async {
|
||||
final wasSaved = _saved.contains(dramaId);
|
||||
final previousCards = cards;
|
||||
final previousTotal = total;
|
||||
final previousHasMore = hasMore;
|
||||
// 乐观更新
|
||||
if (wasSaved) {
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
if (cards.any((drama) => drama.id == dramaId)) {
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
total = total > 0 ? total - 1 : 0;
|
||||
hasMore = cards.length < total;
|
||||
}
|
||||
} else {
|
||||
_saved.add(dramaId);
|
||||
}
|
||||
@@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier {
|
||||
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
|
||||
if (saved) {
|
||||
_saved.add(dramaId);
|
||||
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
|
||||
if (wasSaved) {
|
||||
cards = previousCards;
|
||||
total = previousTotal;
|
||||
hasMore = previousHasMore;
|
||||
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
|
||||
cards = [card, ...cards];
|
||||
total += 1;
|
||||
hasMore = cards.length < total;
|
||||
}
|
||||
} else {
|
||||
_saved.remove(dramaId);
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
if (cards.any((drama) => drama.id == dramaId)) {
|
||||
cards = cards.where((drama) => drama.id != dramaId).toList();
|
||||
total = total > 0 ? total - 1 : 0;
|
||||
hasMore = cards.length < total;
|
||||
}
|
||||
}
|
||||
notify();
|
||||
return true;
|
||||
} catch (_) {
|
||||
// 回滚
|
||||
cards = previousCards;
|
||||
total = previousTotal;
|
||||
hasMore = previousHasMore;
|
||||
if (wasSaved) {
|
||||
_saved.add(dramaId);
|
||||
} else {
|
||||
@@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier {
|
||||
_saved.remove(dramaId);
|
||||
final next = [...cards]..removeAt(index);
|
||||
cards = next;
|
||||
total = total > 0 ? total - 1 : 0;
|
||||
hasMore = cards.length < total;
|
||||
notify();
|
||||
try {
|
||||
await _repository.remove(dramaId);
|
||||
@@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier {
|
||||
);
|
||||
cards = restored;
|
||||
_saved.add(dramaId);
|
||||
total += 1;
|
||||
hasMore = cards.length < total;
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
@@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier {
|
||||
|
||||
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
||||
void clear() {
|
||||
_requestSeq += 1;
|
||||
_saved.clear();
|
||||
cards = const [];
|
||||
total = 0;
|
||||
loading = false;
|
||||
hasMore = false;
|
||||
loadingMore = false;
|
||||
loadMoreError = null;
|
||||
error = null;
|
||||
_pageIndex = 0;
|
||||
notify();
|
||||
}
|
||||
|
||||
List<Drama> _uniqueCards(List<Drama> source) {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final drama in source)
|
||||
if (seen.add(drama.id)) drama,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/repositories/catalog_repository.dart';
|
||||
import '../../../pages/home/home_feed_channel.dart';
|
||||
|
||||
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
|
||||
class HomeFeedController extends ChangeNotifier {
|
||||
HomeFeedController(
|
||||
this._repository, {
|
||||
this.requestTimeout = defaultRequestTimeout,
|
||||
}) : _states = {
|
||||
for (final channel in HomeFeedChannel.values)
|
||||
channel: _ChannelFeedState(),
|
||||
};
|
||||
|
||||
static const int pageSize = 10;
|
||||
static const Duration defaultRequestTimeout = Duration(seconds: 10);
|
||||
|
||||
final CatalogRepository _repository;
|
||||
final Duration requestTimeout;
|
||||
final Map<HomeFeedChannel, _ChannelFeedState> _states;
|
||||
HomeFeedChannel _channel = HomeFeedChannel.recommend;
|
||||
bool _disposed = false;
|
||||
|
||||
HomeFeedChannel get channel => _channel;
|
||||
String? get lastError => _state(_channel).lastError;
|
||||
List<Drama> get dramas => _state(_channel).dramas;
|
||||
String? get cursor => _state(_channel).cursor;
|
||||
bool get hasMore => _state(_channel).hasMore;
|
||||
bool get loaded => _state(_channel).loaded;
|
||||
bool get loadingMore => _state(_channel).loadingMore;
|
||||
bool get loadingFirstPage => !loaded && _state(_channel).loading;
|
||||
|
||||
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
|
||||
void seedRecommend(List<Drama> dramas) {
|
||||
final state = _state(HomeFeedChannel.recommend);
|
||||
state
|
||||
..requestSeq = state.requestSeq + 1
|
||||
..dramas = List.unmodifiable(dramas)
|
||||
..cursor = null
|
||||
..hasMore = false
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..loadingMore = false
|
||||
..lastError = null
|
||||
..inFlight = null;
|
||||
_notifyIfCurrent(HomeFeedChannel.recommend);
|
||||
}
|
||||
|
||||
/// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。
|
||||
Future<void> prefetchChannels() async {
|
||||
await Future.wait([
|
||||
_prefetchChannel(HomeFeedChannel.anime),
|
||||
_prefetchChannel(HomeFeedChannel.live),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。
|
||||
Future<void> selectChannel(HomeFeedChannel next) async {
|
||||
if (next == _channel) return;
|
||||
_channel = next;
|
||||
_notify();
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
if (state.skipNextRefresh) {
|
||||
state.skipNextRefresh = false;
|
||||
} else if (!state.loading) {
|
||||
unawaited(_refresh(next, keepData: true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.loading) {
|
||||
final inFlight = state.inFlight;
|
||||
if (inFlight != null) await inFlight;
|
||||
state.skipNextRefresh = false;
|
||||
return;
|
||||
}
|
||||
await _refresh(next, keepData: false);
|
||||
}
|
||||
|
||||
/// 刷新当前频道,失败时保留旧数据。
|
||||
Future<bool> refreshCurrent() async {
|
||||
final state = _state(_channel);
|
||||
if (state.loading) {
|
||||
return await state.inFlight ?? false;
|
||||
}
|
||||
return _refresh(_channel, keepData: true);
|
||||
}
|
||||
|
||||
/// 加载当前频道下一页并去重追加。
|
||||
Future<void> loadMore() async {
|
||||
final channel = _channel;
|
||||
final state = _state(channel);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final seq = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notifyIfCurrent(channel);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(
|
||||
types: channel.apiTypes,
|
||||
cursor: state.cursor,
|
||||
size: pageSize,
|
||||
)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(channel, seq)) return;
|
||||
final seen = state.dramas.map((drama) => drama.id).toSet();
|
||||
final appended = page.dramas
|
||||
.where((drama) => seen.add(drama.id))
|
||||
.toList();
|
||||
state
|
||||
..dramas = List.unmodifiable([...state.dramas, ...appended])
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..lastError = null;
|
||||
} catch (error) {
|
||||
if (_acceptSequence(channel, seq)) {
|
||||
state.lastError = error.toString();
|
||||
}
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
_notifyIfCurrent(channel);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refresh(HomeFeedChannel channel, {required bool keepData}) {
|
||||
final state = _state(channel);
|
||||
final operation = _performRefresh(channel, keepData: keepData);
|
||||
state.inFlight = operation;
|
||||
unawaited(_clearInFlight(state, operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<void> _prefetchChannel(HomeFeedChannel channel) async {
|
||||
final state = _state(channel);
|
||||
if (state.loaded || state.loading) return;
|
||||
final success = await _refresh(channel, keepData: false);
|
||||
if (success && !_disposed) state.skipNextRefresh = true;
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh(
|
||||
HomeFeedChannel channel, {
|
||||
required bool keepData,
|
||||
}) async {
|
||||
final state = _state(channel);
|
||||
final seq = ++state.requestSeq;
|
||||
state
|
||||
..loading = true
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(channel);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(types: channel.apiTypes, size: pageSize)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(channel, seq)) return false;
|
||||
state
|
||||
..dramas = List.unmodifiable(page.dramas)
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(channel);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_acceptSequence(channel, seq)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = error.toString();
|
||||
_notifyIfCurrent(channel);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearInFlight(
|
||||
_ChannelFeedState state,
|
||||
Future<bool> operation,
|
||||
) async {
|
||||
await operation;
|
||||
if (identical(state.inFlight, operation)) state.inFlight = null;
|
||||
}
|
||||
|
||||
_ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!;
|
||||
|
||||
bool _acceptSequence(HomeFeedChannel channel, int seq) =>
|
||||
!_disposed && _state(channel).requestSeq == seq;
|
||||
|
||||
void _notifyIfCurrent(HomeFeedChannel channel) {
|
||||
if (!_disposed && channel == _channel) _notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
String? lastError;
|
||||
int requestSeq = 0;
|
||||
Future<bool>? inFlight;
|
||||
bool skipNextRefresh = false;
|
||||
}
|
||||
@@ -17,6 +17,9 @@ class InteractionController extends ChangeNotifier {
|
||||
final Set<String> _likedDramaIds = {};
|
||||
final Set<String> _busyDramaIds = {};
|
||||
final Map<String, int> _likeCounts = {};
|
||||
final Set<String> _likedEpisodeIds = {};
|
||||
final Set<String> _busyEpisodeIds = {};
|
||||
final Map<String, int> _episodeLikeCounts = {};
|
||||
bool _disposed = false;
|
||||
|
||||
/// 当前会话内该剧是否已点赞。
|
||||
@@ -28,42 +31,75 @@ class InteractionController extends ChangeNotifier {
|
||||
/// 服务端最新点赞数,仅在点赞接口成功返回后可用。
|
||||
int? likeCount(String dramaId) => _likeCounts[dramaId];
|
||||
|
||||
/// 当前会话内该集是否已点赞。
|
||||
bool isEpisodeLiked(String episodeId) => _likedEpisodeIds.contains(episodeId);
|
||||
|
||||
/// 该集点赞请求是否进行中。
|
||||
bool isEpisodeBusy(String episodeId) => _busyEpisodeIds.contains(episodeId);
|
||||
|
||||
/// 该集服务端最新点赞数。
|
||||
int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId];
|
||||
|
||||
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
|
||||
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
|
||||
String dramaId,
|
||||
) async {
|
||||
if (_busyDramaIds.contains(dramaId)) {
|
||||
Future<OperationResult<LikeToggleResult>> toggleDramaLike(String dramaId) =>
|
||||
_toggleLike(
|
||||
id: dramaId,
|
||||
targetType: InteractionTargetType.drama,
|
||||
likedIds: _likedDramaIds,
|
||||
busyIds: _busyDramaIds,
|
||||
counts: _likeCounts,
|
||||
);
|
||||
|
||||
/// 乐观切换分集点赞,失败时回滚并以服务端返回状态校准。
|
||||
Future<OperationResult<LikeToggleResult>> toggleEpisodeLike(
|
||||
String episodeId,
|
||||
) => _toggleLike(
|
||||
id: episodeId,
|
||||
targetType: InteractionTargetType.episode,
|
||||
likedIds: _likedEpisodeIds,
|
||||
busyIds: _busyEpisodeIds,
|
||||
counts: _episodeLikeCounts,
|
||||
);
|
||||
|
||||
Future<OperationResult<LikeToggleResult>> _toggleLike({
|
||||
required String id,
|
||||
required InteractionTargetType targetType,
|
||||
required Set<String> likedIds,
|
||||
required Set<String> busyIds,
|
||||
required Map<String, int> counts,
|
||||
}) async {
|
||||
if (busyIds.contains(id)) {
|
||||
return const OperationResult.failure(null);
|
||||
}
|
||||
final wasLiked = _likedDramaIds.contains(dramaId);
|
||||
_busyDramaIds.add(dramaId);
|
||||
final wasLiked = likedIds.contains(id);
|
||||
busyIds.add(id);
|
||||
if (wasLiked) {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
likedIds.remove(id);
|
||||
} else {
|
||||
_likedDramaIds.add(dramaId);
|
||||
likedIds.add(id);
|
||||
}
|
||||
_notify();
|
||||
try {
|
||||
final result = await _repository.toggleLike(
|
||||
targetType: InteractionTargetType.drama,
|
||||
targetId: dramaId,
|
||||
targetType: targetType,
|
||||
targetId: id,
|
||||
);
|
||||
if (result.liked) {
|
||||
_likedDramaIds.add(dramaId);
|
||||
likedIds.add(id);
|
||||
} else {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
likedIds.remove(id);
|
||||
}
|
||||
_likeCounts[dramaId] = result.count;
|
||||
counts[id] = result.count;
|
||||
return OperationResult.success(result);
|
||||
} catch (failure) {
|
||||
if (wasLiked) {
|
||||
_likedDramaIds.add(dramaId);
|
||||
likedIds.add(id);
|
||||
} else {
|
||||
_likedDramaIds.remove(dramaId);
|
||||
likedIds.remove(id);
|
||||
}
|
||||
return OperationResult.failure(_errorMessage(failure));
|
||||
} finally {
|
||||
_busyDramaIds.remove(dramaId);
|
||||
busyIds.remove(id);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
@@ -93,12 +129,18 @@ class InteractionController extends ChangeNotifier {
|
||||
void clear() {
|
||||
if (_likedDramaIds.isEmpty &&
|
||||
_busyDramaIds.isEmpty &&
|
||||
_likeCounts.isEmpty) {
|
||||
_likeCounts.isEmpty &&
|
||||
_likedEpisodeIds.isEmpty &&
|
||||
_busyEpisodeIds.isEmpty &&
|
||||
_episodeLikeCounts.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_likedDramaIds.clear();
|
||||
_busyDramaIds.clear();
|
||||
_likeCounts.clear();
|
||||
_likedEpisodeIds.clear();
|
||||
_busyEpisodeIds.clear();
|
||||
_episodeLikeCounts.clear();
|
||||
_notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
const Duration _defaultMaxAge = Duration(hours: 12);
|
||||
|
||||
/// 将播放位置限制在当前媒体时长内。
|
||||
Duration clampPlaybackPosition(Duration position, Duration duration) {
|
||||
if (position <= Duration.zero || duration <= Duration.zero) {
|
||||
return Duration.zero;
|
||||
}
|
||||
if (position >= duration) return duration;
|
||||
return position;
|
||||
}
|
||||
|
||||
/// 进程内最近播放位置。
|
||||
class CachedPlaybackPosition {
|
||||
const CachedPlaybackPosition({
|
||||
required this.episodeId,
|
||||
required this.dramaId,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String episodeId;
|
||||
final String dramaId;
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final DateTime updatedAt;
|
||||
}
|
||||
|
||||
/// 按集缓存最近播放位置,仅当前进程有效。
|
||||
class PlaybackPositionCache {
|
||||
PlaybackPositionCache({
|
||||
DateTime Function()? now,
|
||||
this.maxAge = _defaultMaxAge,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
final DateTime Function() _now;
|
||||
final Duration maxAge;
|
||||
final Map<String, CachedPlaybackPosition> _positions = {};
|
||||
|
||||
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
|
||||
void save({
|
||||
required String episodeId,
|
||||
required String dramaId,
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
}) {
|
||||
final safePosition = clampPlaybackPosition(position, duration);
|
||||
if (episodeId.isEmpty || dramaId.isEmpty || safePosition <= Duration.zero) {
|
||||
return;
|
||||
}
|
||||
_positions[episodeId] = CachedPlaybackPosition(
|
||||
episodeId: episodeId,
|
||||
dramaId: dramaId,
|
||||
position: safePosition,
|
||||
duration: duration,
|
||||
updatedAt: _now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 读取同剧同集且未过期的最近位置。
|
||||
CachedPlaybackPosition? read(String episodeId, {required String dramaId}) {
|
||||
final entry = _positions[episodeId];
|
||||
if (!_isValid(entry, dramaId)) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// 读取指定短剧最近更新的有效位置。
|
||||
CachedPlaybackPosition? latestForDrama(String dramaId) {
|
||||
CachedPlaybackPosition? latest;
|
||||
for (final entry in _positions.values) {
|
||||
if (!_isValid(entry, dramaId)) continue;
|
||||
if (latest == null || !entry.updatedAt.isBefore(latest.updatedAt)) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
bool _isValid(CachedPlaybackPosition? entry, String dramaId) {
|
||||
if (entry == null ||
|
||||
entry.dramaId != dramaId ||
|
||||
entry.position <= Duration.zero) {
|
||||
return false;
|
||||
}
|
||||
return _now().difference(entry.updatedAt) <= maxAge;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
this.prepare,
|
||||
this.activate,
|
||||
PlaybackCoordinator? coordinator,
|
||||
this.waitForFrame,
|
||||
}) : _coordinator = coordinator ?? PlaybackCoordinator();
|
||||
|
||||
final List<Episode> episodes;
|
||||
@@ -60,6 +61,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
|
||||
/// Runs only when a slot becomes current (e.g. progress listeners).
|
||||
final PlayerPreparation? activate;
|
||||
final Future<void> Function()? waitForFrame;
|
||||
final PlaybackCoordinator _coordinator;
|
||||
final Set<_PlayerSlot> _live = {};
|
||||
_PlayerSlot? _active;
|
||||
@@ -80,8 +82,17 @@ class PlaybackSession extends ChangeNotifier {
|
||||
|
||||
int get currentIndex => _currentIndex;
|
||||
Episode get currentEpisode => episodes[_currentIndex];
|
||||
PlayerPort? get active =>
|
||||
_active?.index == _currentIndex ? _active?.player : null;
|
||||
PlayerPort? get active {
|
||||
final slot = _active;
|
||||
if (_closed ||
|
||||
slot == null ||
|
||||
slot.retiring ||
|
||||
slot.index != _currentIndex) {
|
||||
return null;
|
||||
}
|
||||
return slot.player;
|
||||
}
|
||||
|
||||
bool get loading => _loading;
|
||||
bool get userWantsPlay => _userWantsPlay;
|
||||
bool get canAutoAdvance => _mayPlay && !_loading && active != null;
|
||||
@@ -93,18 +104,50 @@ class PlaybackSession extends ChangeNotifier {
|
||||
Future<void> get playbackSettled => _operations;
|
||||
Future<void> get preloadSettled => _preloadTask;
|
||||
|
||||
/// 当前页面可见、无浮层遮挡且存在可用播放器时可安全恢复。
|
||||
bool get canResumeAfterBackground =>
|
||||
!_closed && _routeVisible && !_overlayOpen && active != null;
|
||||
|
||||
bool get _visible => !_closed && _appResumed && _routeVisible;
|
||||
bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay;
|
||||
|
||||
/// Exposes only prepared slots so the adjacent page can mount its video texture
|
||||
/// during a swipe without starting audio or registering history listeners.
|
||||
PlayerPort? preparedPlayerAt(int index) {
|
||||
if (_closed) return null;
|
||||
for (final slot in [_active, _next]) {
|
||||
if (slot?.index == index && slot!.prepared) return slot.player;
|
||||
if (slot == null ||
|
||||
slot.retiring ||
|
||||
slot.index != index ||
|
||||
!slot.prepared) {
|
||||
continue;
|
||||
}
|
||||
return slot.player;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
|
||||
void remapCurrentIndex(int index, {int? preloadIndex}) {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
final activeSlot = _active;
|
||||
if (activeSlot == null || activeSlot.index != _currentIndex) {
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
_generation++;
|
||||
activeSlot.index = index;
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
final staleNext = _next;
|
||||
_next = null;
|
||||
_notify();
|
||||
unawaited(_enqueue(() async => _retire(staleNext)));
|
||||
if (_visible && !_overlayOpen) _schedulePreload();
|
||||
}
|
||||
|
||||
Future<void> _enqueue(Future<void> Function() operation) {
|
||||
final task = _operations.then((_) => operation());
|
||||
// A plugin failure must not poison subsequent transitions or cleanup.
|
||||
@@ -141,6 +184,11 @@ class PlaybackSession extends ChangeNotifier {
|
||||
Future<void> _retire(_PlayerSlot? slot) async {
|
||||
if (slot == null || !_live.contains(slot)) return;
|
||||
slot.retiring = true;
|
||||
final cleared = _active == slot || _next == slot;
|
||||
if (_active == slot) _active = null;
|
||||
if (_next == slot) _next = null;
|
||||
if (cleared) _notify();
|
||||
await waitForFrame?.call();
|
||||
await slot.ready;
|
||||
try {
|
||||
await slot.player.pause();
|
||||
@@ -363,7 +411,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
|
||||
class _PlayerSlot {
|
||||
_PlayerSlot(this.index, this.player);
|
||||
final int index;
|
||||
int index;
|
||||
final PlayerPort player;
|
||||
late final Future<void> ready;
|
||||
bool prepared = false;
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 原生取帧所需的播放源信息。
|
||||
class PlaybackThumbnailSource {
|
||||
const PlaybackThumbnailSource({
|
||||
this.localPath = '',
|
||||
this.remoteUrl = '',
|
||||
this.headers = const {},
|
||||
});
|
||||
|
||||
final String localPath;
|
||||
final String remoteUrl;
|
||||
final Map<String, String> headers;
|
||||
|
||||
bool get isAvailable => localPath.isNotEmpty || remoteUrl.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 按 episodeId 读取当前可用播放源,确保签名地址刷新后立即生效。
|
||||
typedef PlaybackThumbnailSourceResolver =
|
||||
PlaybackThumbnailSource? Function(String episodeId);
|
||||
|
||||
/// 传给原生取帧层的单次请求。
|
||||
class PlaybackThumbnailFrameRequest {
|
||||
const PlaybackThumbnailFrameRequest({
|
||||
required this.episodeId,
|
||||
required this.position,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
final String episodeId;
|
||||
final Duration position;
|
||||
final PlaybackThumbnailSource source;
|
||||
|
||||
Map<String, Object> toChannelArguments() => {
|
||||
'episodeId': episodeId,
|
||||
'timeMs': position.inMilliseconds,
|
||||
'localPath': source.localPath,
|
||||
'url': source.remoteUrl,
|
||||
'headers': source.headers,
|
||||
};
|
||||
}
|
||||
|
||||
/// 可注入的原生取帧接口。
|
||||
abstract interface class PlaybackThumbnailFrameLoader {
|
||||
Future<ByteData?> load(PlaybackThumbnailFrameRequest request);
|
||||
|
||||
void dispose();
|
||||
}
|
||||
|
||||
/// 默认通过平台通道从本地播放器原生层取帧。
|
||||
class MethodChannelPlaybackThumbnailFrameLoader
|
||||
implements PlaybackThumbnailFrameLoader {
|
||||
MethodChannelPlaybackThumbnailFrameLoader({MethodChannel? channel})
|
||||
: _channel =
|
||||
channel ?? const MethodChannel('com.stormg.lumadrama/thumbnail');
|
||||
|
||||
final MethodChannel _channel;
|
||||
|
||||
@override
|
||||
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async {
|
||||
final bytes = await _channel.invokeMethod<Uint8List>(
|
||||
'frame',
|
||||
request.toChannelArguments(),
|
||||
);
|
||||
return bytes == null ? null : ByteData.sublistView(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
/// 每秒一个 bucket,拖过同一秒时复用同一张缩略图。
|
||||
int playbackThumbnailBucket(Duration position) {
|
||||
final milliseconds = position.inMilliseconds;
|
||||
if (milliseconds <= 0) return 0;
|
||||
return milliseconds ~/ Duration.millisecondsPerSecond;
|
||||
}
|
||||
|
||||
/// 播放器缩略图单飞、LRU 缓存与拖动预取调度。
|
||||
class PlaybackThumbnailService {
|
||||
PlaybackThumbnailService({
|
||||
PlaybackThumbnailFrameLoader? frameLoader,
|
||||
this.sourceResolver,
|
||||
this.requestTimeout = const Duration(milliseconds: 1500),
|
||||
this.prefetchDelay = const Duration(milliseconds: 120),
|
||||
this.cacheCapacity = defaultCacheCapacity,
|
||||
}) : _frameLoader =
|
||||
frameLoader ?? MethodChannelPlaybackThumbnailFrameLoader(),
|
||||
assert(cacheCapacity > 0);
|
||||
|
||||
static const int defaultCacheCapacity = 24;
|
||||
|
||||
final PlaybackThumbnailFrameLoader _frameLoader;
|
||||
final PlaybackThumbnailSourceResolver? sourceResolver;
|
||||
final Duration requestTimeout;
|
||||
final Duration prefetchDelay;
|
||||
final int cacheCapacity;
|
||||
final LinkedHashMap<String, ByteData> _cache = LinkedHashMap();
|
||||
final LinkedHashMap<String, bool> _unavailable = LinkedHashMap();
|
||||
|
||||
_ThumbnailRequest? _inFlight;
|
||||
Future<ByteData?>? _inFlightFuture;
|
||||
_ThumbnailRequest? _queued;
|
||||
Timer? _prefetchTimer;
|
||||
String? _wantedKey;
|
||||
String? _latestEpisodeId;
|
||||
int? _latestBucket;
|
||||
int _prefetchDirection = 1;
|
||||
bool _disposed = false;
|
||||
|
||||
@visibleForTesting
|
||||
int get cacheSize => _cache.length;
|
||||
|
||||
/// 取指定剧集、时间点的缩略图。失败或超时统一返回 null。
|
||||
Future<ByteData?> thumbnail(String episodeId, Duration position) {
|
||||
if (_disposed) return Future<ByteData?>.value();
|
||||
final bucket = playbackThumbnailBucket(position);
|
||||
final key = _key(episodeId, bucket);
|
||||
_wantedKey = key;
|
||||
_noteRequestPosition(episodeId, bucket);
|
||||
_prefetchTimer?.cancel();
|
||||
_prefetchTimer = null;
|
||||
_dropQueued();
|
||||
|
||||
final cached = _readCache(key);
|
||||
if (cached != null) {
|
||||
_schedulePrefetch(key);
|
||||
return Future<ByteData?>.value(cached);
|
||||
}
|
||||
if (_inFlight?.key == key && _inFlightFuture != null) {
|
||||
_schedulePrefetch(key);
|
||||
return _inFlightFuture!;
|
||||
}
|
||||
if (_unavailable.containsKey(key)) {
|
||||
_promoteUnavailable(key);
|
||||
_schedulePrefetch(key);
|
||||
return Future<ByteData?>.value();
|
||||
}
|
||||
|
||||
final request = _ThumbnailRequest(
|
||||
key: key,
|
||||
episodeId: episodeId,
|
||||
bucket: bucket,
|
||||
);
|
||||
_schedulePrefetch(key);
|
||||
if (_inFlight == null) return _start(request);
|
||||
return _queue(request);
|
||||
}
|
||||
|
||||
/// 松手或离开拖动后调用;只取消尚未开始的请求。
|
||||
void cancelPending() {
|
||||
_wantedKey = null;
|
||||
_prefetchTimer?.cancel();
|
||||
_prefetchTimer = null;
|
||||
_dropQueued();
|
||||
}
|
||||
|
||||
/// 页面销毁时清空帧缓存和所有待执行请求。
|
||||
void clear() {
|
||||
cancelPending();
|
||||
_cache.clear();
|
||||
_unavailable.clear();
|
||||
_latestEpisodeId = null;
|
||||
_latestBucket = null;
|
||||
_prefetchDirection = 1;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
clear();
|
||||
_frameLoader.dispose();
|
||||
}
|
||||
|
||||
Future<ByteData?> _start(_ThumbnailRequest request) {
|
||||
final completer = Completer<ByteData?>();
|
||||
_inFlight = request;
|
||||
_inFlightFuture = completer.future;
|
||||
unawaited(_run(request, completer));
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<ByteData?> _queue(_ThumbnailRequest request) {
|
||||
final completer = Completer<ByteData?>();
|
||||
_queued = request.withCompleter(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _run(
|
||||
_ThumbnailRequest request,
|
||||
Completer<ByteData?> completer,
|
||||
) async {
|
||||
ByteData? frame;
|
||||
try {
|
||||
frame = await _loadFrame(request);
|
||||
} catch (_) {
|
||||
frame = null;
|
||||
}
|
||||
if (frame == null) {
|
||||
_putUnavailable(request.key);
|
||||
} else {
|
||||
_putCache(request.key, frame);
|
||||
}
|
||||
final deliver = _wantedKey == request.key;
|
||||
_inFlight = null;
|
||||
_inFlightFuture = null;
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(deliver ? frame : null);
|
||||
}
|
||||
_drainQueued();
|
||||
}
|
||||
|
||||
Future<ByteData?> _loadFrame(_ThumbnailRequest request) {
|
||||
PlaybackThumbnailSource? source;
|
||||
try {
|
||||
source = sourceResolver?.call(request.episodeId);
|
||||
} catch (_) {
|
||||
return Future<ByteData?>.value();
|
||||
}
|
||||
if (source == null || !source.isAvailable) {
|
||||
return Future<ByteData?>.value();
|
||||
}
|
||||
final frame = PlaybackThumbnailFrameRequest(
|
||||
episodeId: request.episodeId,
|
||||
position: Duration(seconds: request.bucket),
|
||||
source: source,
|
||||
);
|
||||
final result = Completer<ByteData?>();
|
||||
late final Timer timeout;
|
||||
void finish(ByteData? value) {
|
||||
if (result.isCompleted) return;
|
||||
timeout.cancel();
|
||||
result.complete(value);
|
||||
}
|
||||
|
||||
timeout = Timer(requestTimeout, () => finish(null));
|
||||
try {
|
||||
_frameLoader
|
||||
.load(frame)
|
||||
.then(finish, onError: (Object _, StackTrace _) => finish(null));
|
||||
} catch (_) {
|
||||
finish(null);
|
||||
}
|
||||
return result.future;
|
||||
}
|
||||
|
||||
void _drainQueued() {
|
||||
if (_disposed || _inFlight != null) return;
|
||||
final queued = _queued;
|
||||
_queued = null;
|
||||
if (queued == null || queued.key != _wantedKey) {
|
||||
queued?.completer.complete(null);
|
||||
return;
|
||||
}
|
||||
_inFlight = queued;
|
||||
_inFlightFuture = queued.completer.future;
|
||||
unawaited(_run(queued, queued.completer));
|
||||
}
|
||||
|
||||
void _dropQueued() {
|
||||
final queued = _queued;
|
||||
_queued = null;
|
||||
if (queued != null && !queued.completer.isCompleted) {
|
||||
queued.completer.complete(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _noteRequestPosition(String episodeId, int bucket) {
|
||||
if (_latestEpisodeId == episodeId && _latestBucket != null) {
|
||||
if (bucket < _latestBucket!) _prefetchDirection = -1;
|
||||
if (bucket > _latestBucket!) _prefetchDirection = 1;
|
||||
}
|
||||
_latestEpisodeId = episodeId;
|
||||
_latestBucket = bucket;
|
||||
}
|
||||
|
||||
void _schedulePrefetch(String currentKey) {
|
||||
_prefetchTimer?.cancel();
|
||||
_prefetchTimer = Timer(prefetchDelay, () {
|
||||
_prefetchTimer = null;
|
||||
if (_disposed || _wantedKey != currentKey) return;
|
||||
final episodeId = _latestEpisodeId;
|
||||
final bucket = _latestBucket;
|
||||
if (episodeId == null || bucket == null) return;
|
||||
final neighbor = bucket + _prefetchDirection;
|
||||
if (neighbor < 0) return;
|
||||
final key = _key(episodeId, neighbor);
|
||||
if (_cache.containsKey(key) ||
|
||||
_unavailable.containsKey(key) ||
|
||||
_inFlight?.key == key ||
|
||||
_queued != null) {
|
||||
return;
|
||||
}
|
||||
final request = _ThumbnailRequest(
|
||||
key: key,
|
||||
episodeId: episodeId,
|
||||
bucket: neighbor,
|
||||
);
|
||||
if (_inFlight == null) {
|
||||
unawaited(_start(request));
|
||||
} else {
|
||||
_queue(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ByteData? _readCache(String key) {
|
||||
final value = _cache.remove(key);
|
||||
if (value == null) return null;
|
||||
_cache[key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
void _putCache(String key, ByteData value) {
|
||||
_unavailable.remove(key);
|
||||
_cache.remove(key);
|
||||
_cache[key] = value;
|
||||
while (_cache.length > cacheCapacity) {
|
||||
_cache.remove(_cache.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
void _putUnavailable(String key) {
|
||||
_unavailable.remove(key);
|
||||
_unavailable[key] = true;
|
||||
while (_unavailable.length > cacheCapacity) {
|
||||
_unavailable.remove(_unavailable.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
void _promoteUnavailable(String key) {
|
||||
if (_unavailable.remove(key) == null) return;
|
||||
_unavailable[key] = true;
|
||||
}
|
||||
|
||||
String _key(String episodeId, int bucket) => '$episodeId:$bucket';
|
||||
}
|
||||
|
||||
class _ThumbnailRequest {
|
||||
_ThumbnailRequest({
|
||||
required this.key,
|
||||
required this.episodeId,
|
||||
required this.bucket,
|
||||
Completer<ByteData?>? completer,
|
||||
}) : completer = completer ?? Completer<ByteData?>();
|
||||
|
||||
final String key;
|
||||
final String episodeId;
|
||||
final int bucket;
|
||||
final Completer<ByteData?> completer;
|
||||
|
||||
_ThumbnailRequest withCompleter(Completer<ByteData?> value) =>
|
||||
_ThumbnailRequest(
|
||||
key: key,
|
||||
episodeId: episodeId,
|
||||
bucket: bucket,
|
||||
completer: value,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
const int spriteThumbnailDecodeMaxDimension = 2048;
|
||||
|
||||
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
|
||||
class SpriteThumbnailForbiddenException implements Exception {
|
||||
const SpriteThumbnailForbiddenException();
|
||||
}
|
||||
|
||||
/// 已解码 sprite 与原图尺寸;cue 坐标会按解码缩放比例映射。
|
||||
class SpriteThumbnailDecodedImage {
|
||||
const SpriteThumbnailDecodedImage({
|
||||
required this.image,
|
||||
required this.originalWidth,
|
||||
required this.originalHeight,
|
||||
});
|
||||
|
||||
final ui.Image image;
|
||||
final int originalWidth;
|
||||
final int originalHeight;
|
||||
}
|
||||
|
||||
/// 可注入的资源加载接口,测试无需真实网络或图片。
|
||||
abstract interface class SpriteThumbnailAssetLoader {
|
||||
Future<String?> loadVtt(Uri uri);
|
||||
|
||||
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri);
|
||||
}
|
||||
|
||||
/// 默认通过项目现有 Dio 拉取 VTT 和 sprite 图片。
|
||||
class DioSpriteThumbnailAssetLoader implements SpriteThumbnailAssetLoader {
|
||||
DioSpriteThumbnailAssetLoader({Dio? dio})
|
||||
: _dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: AppConfig.requestTimeout,
|
||||
receiveTimeout: AppConfig.requestTimeout,
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<String?> loadVtt(Uri uri) async {
|
||||
final response = await _request(uri);
|
||||
if (response?.statusCode == 403) {
|
||||
throw const SpriteThumbnailForbiddenException();
|
||||
}
|
||||
if (response == null || !_isSuccess(response.statusCode)) return null;
|
||||
final data = response.data;
|
||||
if (data is! List<int>) return null;
|
||||
return utf8.decode(data, allowMalformed: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
|
||||
final response = await _request(uri);
|
||||
if (response == null || !_isSuccess(response.statusCode)) return null;
|
||||
final data = response.data;
|
||||
if (data is! List<int>) return null;
|
||||
return _decode(Uint8List.fromList(data));
|
||||
}
|
||||
|
||||
Future<Response<List<int>>?> _request(Uri uri) async {
|
||||
try {
|
||||
return await _dio.get<List<int>>(
|
||||
uri.toString(),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSuccess(int? status) =>
|
||||
status != null && status >= 200 && status < 300;
|
||||
|
||||
Future<SpriteThumbnailDecodedImage?> _decode(Uint8List bytes) async {
|
||||
ui.ImmutableBuffer? buffer;
|
||||
ui.ImageDescriptor? descriptor;
|
||||
ui.Codec? codec;
|
||||
try {
|
||||
buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||||
final originalWidth = descriptor.width;
|
||||
final originalHeight = descriptor.height;
|
||||
final target = _targetSize(originalWidth, originalHeight);
|
||||
codec = await descriptor.instantiateCodec(
|
||||
targetWidth: target.$1,
|
||||
targetHeight: target.$2,
|
||||
);
|
||||
final frame = await codec.getNextFrame();
|
||||
return SpriteThumbnailDecodedImage(
|
||||
image: frame.image,
|
||||
originalWidth: originalWidth,
|
||||
originalHeight: originalHeight,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
codec?.dispose();
|
||||
descriptor?.dispose();
|
||||
buffer?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
(int?, int?) _targetSize(int width, int height) {
|
||||
final longest = width > height ? width : height;
|
||||
if (longest <= spriteThumbnailDecodeMaxDimension) {
|
||||
return (null, null);
|
||||
}
|
||||
final scale = spriteThumbnailDecodeMaxDimension / longest;
|
||||
return (
|
||||
(width * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension),
|
||||
(height * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:collection';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import 'sprite_thumbnail_asset_loader.dart';
|
||||
|
||||
const Duration spriteVttCacheTtl = Duration(minutes: 10);
|
||||
const int spriteImageCacheCapacity = 2;
|
||||
|
||||
/// 单个 VTT cue 对应的 sprite 裁剪区域。
|
||||
class SpriteThumbnailCue {
|
||||
const SpriteThumbnailCue({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.spriteFile,
|
||||
required this.sourceRect,
|
||||
});
|
||||
|
||||
final Duration start;
|
||||
final Duration end;
|
||||
final String spriteFile;
|
||||
final ui.Rect sourceRect;
|
||||
}
|
||||
|
||||
/// 已解析的 VTT 时间轴。
|
||||
class SpriteThumbnailAxis {
|
||||
const SpriteThumbnailAxis({required this.vttUrl, required this.cues});
|
||||
|
||||
final String vttUrl;
|
||||
final List<SpriteThumbnailCue> cues;
|
||||
|
||||
factory SpriteThumbnailAxis.empty(String vttUrl) =>
|
||||
SpriteThumbnailAxis(vttUrl: vttUrl, cues: const []);
|
||||
|
||||
Uri resolveSpriteUri(String spriteFile) =>
|
||||
Uri.parse(vttUrl).resolve(spriteFile);
|
||||
}
|
||||
|
||||
/// 可直接交给 RawImage.sourceRect 的裁剪帧。
|
||||
class SpriteThumbnailFrame {
|
||||
const SpriteThumbnailFrame({
|
||||
required this.image,
|
||||
required this.sourceRect,
|
||||
required this.spriteUrl,
|
||||
required this.cue,
|
||||
});
|
||||
|
||||
final ui.Image image;
|
||||
final ui.Rect sourceRect;
|
||||
final String spriteUrl;
|
||||
final SpriteThumbnailCue cue;
|
||||
}
|
||||
|
||||
/// 预览卡片当前应展示的回退阶段。
|
||||
enum ScrubPreviewStage { sprite, waitingForAxis, localFrame, cover }
|
||||
|
||||
/// 预览回退链的纯判定函数。
|
||||
ScrubPreviewStage decideScrubPreviewStage({
|
||||
required bool hasThumbnailsUrl,
|
||||
required bool hasSpriteFrame,
|
||||
required bool hasAxis,
|
||||
required bool hasLocalFrame,
|
||||
}) {
|
||||
if (hasSpriteFrame) return ScrubPreviewStage.sprite;
|
||||
if (hasThumbnailsUrl && !hasAxis) {
|
||||
return ScrubPreviewStage.waitingForAxis;
|
||||
}
|
||||
if (hasLocalFrame) return ScrubPreviewStage.localFrame;
|
||||
return ScrubPreviewStage.cover;
|
||||
}
|
||||
|
||||
/// 解析 WebVTT;任意异常都收敛为空轴。
|
||||
SpriteThumbnailAxis parseSpriteThumbnailVtt(
|
||||
String source, {
|
||||
required String vttUrl,
|
||||
}) {
|
||||
final cues = <SpriteThumbnailCue>[];
|
||||
try {
|
||||
for (final block
|
||||
in source.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) {
|
||||
final cue = _parseCueBlock(block);
|
||||
if (cue != null) cues.add(cue);
|
||||
}
|
||||
} catch (_) {
|
||||
return SpriteThumbnailAxis.empty(vttUrl);
|
||||
}
|
||||
cues.sort((left, right) => left.start.compareTo(right.start));
|
||||
return SpriteThumbnailAxis(vttUrl: vttUrl, cues: cues);
|
||||
}
|
||||
|
||||
/// 按 position 二分选择 cue;边界外选择最近 cue。
|
||||
SpriteThumbnailCue? selectSpriteThumbnailCue(
|
||||
List<SpriteThumbnailCue> cues,
|
||||
Duration position,
|
||||
) {
|
||||
if (cues.isEmpty) return null;
|
||||
var low = 0;
|
||||
var high = cues.length;
|
||||
while (low < high) {
|
||||
final middle = (low + high) >> 1;
|
||||
if (cues[middle].start <= position) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
if (low == 0) return cues.first;
|
||||
final previous = cues[low - 1];
|
||||
if (low == cues.length || position <= previous.end) return previous;
|
||||
final next = cues[low];
|
||||
final previousDistance = position - previous.end;
|
||||
final nextDistance = next.start - position;
|
||||
return previousDistance <= nextDistance ? previous : next;
|
||||
}
|
||||
|
||||
/// 只读缓存接口,便于 widget 测试注入。
|
||||
abstract interface class SpriteThumbnailAxisSource implements Listenable {
|
||||
SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position);
|
||||
|
||||
bool hasAxis(String? thumbnailsUrl);
|
||||
}
|
||||
|
||||
/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。
|
||||
class SpriteThumbnailAxisService extends ChangeNotifier
|
||||
implements SpriteThumbnailAxisSource {
|
||||
SpriteThumbnailAxisService({
|
||||
SpriteThumbnailAssetLoader? loader,
|
||||
this.cacheTtl = spriteVttCacheTtl,
|
||||
DateTime Function()? now,
|
||||
}) : _loader = loader ?? DioSpriteThumbnailAssetLoader(),
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final SpriteThumbnailAssetLoader _loader;
|
||||
final Duration cacheTtl;
|
||||
final DateTime Function() _now;
|
||||
final Map<String, _CachedAxis> _axisCache = {};
|
||||
final Map<String, Future<SpriteThumbnailAxis>> _axisRequests = {};
|
||||
final LinkedHashMap<String, _CachedSprite> _spriteCache = LinkedHashMap();
|
||||
final Map<String, Future<_CachedSprite?>> _spriteRequests = {};
|
||||
final Set<String> _failedSprites = {};
|
||||
String? _activeAxisUrl;
|
||||
int _axisGeneration = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
@visibleForTesting
|
||||
int get spriteCacheSize => _spriteCache.length;
|
||||
|
||||
@visibleForTesting
|
||||
List<String> get spriteCacheKeys => _spriteCache.keys.toList();
|
||||
|
||||
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
|
||||
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
|
||||
final url = _normalizeUrl(thumbnailsUrl);
|
||||
if (_disposed || url == null) return;
|
||||
_activateAxis(url);
|
||||
final axis = await _loadAxis(url);
|
||||
if (_disposed || _activeAxisUrl != url) return;
|
||||
for (final cue in _prefetchCues(axis, position)) {
|
||||
await _ensureSprite(axis, cue);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool hasAxis(String? thumbnailsUrl) {
|
||||
final url = _normalizeUrl(thumbnailsUrl);
|
||||
if (url == null || url != _activeAxisUrl) return false;
|
||||
return _readAxis(url) != null;
|
||||
}
|
||||
|
||||
@override
|
||||
SpriteThumbnailFrame? cachedFrameAt(
|
||||
String? thumbnailsUrl,
|
||||
Duration position,
|
||||
) {
|
||||
final url = _normalizeUrl(thumbnailsUrl);
|
||||
if (url == null || url != _activeAxisUrl) return null;
|
||||
final axis = _readAxis(url);
|
||||
if (axis == null) return null;
|
||||
final cue = selectSpriteThumbnailCue(axis.cues, position);
|
||||
if (cue == null) return null;
|
||||
final cached = _readSprite(cue.spriteFile);
|
||||
if (cached == null) return null;
|
||||
final sourceRect = _scaledSourceRect(cached, cue.sourceRect);
|
||||
if (sourceRect == null) return null;
|
||||
return SpriteThumbnailFrame(
|
||||
image: cached.decoded.image,
|
||||
sourceRect: sourceRect,
|
||||
spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(),
|
||||
cue: cue,
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面销毁时释放全部 VTT 与 ui.Image 内存。
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_clearAll();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _activateAxis(String url) {
|
||||
if (_activeAxisUrl == url) return;
|
||||
_activeAxisUrl = url;
|
||||
_axisGeneration++;
|
||||
_clearSprites();
|
||||
}
|
||||
|
||||
Future<SpriteThumbnailAxis> _loadAxis(String url) {
|
||||
final cached = _readAxis(url);
|
||||
if (cached != null) return Future.value(cached);
|
||||
final existing = _axisRequests[url];
|
||||
if (existing != null) return existing;
|
||||
final request = _fetchAxis(url);
|
||||
_axisRequests[url] = request;
|
||||
return request.whenComplete(() {
|
||||
_axisRequests.remove(url);
|
||||
});
|
||||
}
|
||||
|
||||
Future<SpriteThumbnailAxis> _fetchAxis(String url) async {
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
final text = await _loader.loadVtt(Uri.parse(url));
|
||||
final axis = text == null
|
||||
? SpriteThumbnailAxis.empty(url)
|
||||
: parseSpriteThumbnailVtt(text, vttUrl: url);
|
||||
if (_disposed) return SpriteThumbnailAxis.empty(url);
|
||||
_cacheAxis(axis);
|
||||
notifyListeners();
|
||||
return axis;
|
||||
} on SpriteThumbnailForbiddenException {
|
||||
if (_disposed) return SpriteThumbnailAxis.empty(url);
|
||||
_axisCache.remove(url);
|
||||
if (_activeAxisUrl == url) _clearSprites();
|
||||
} catch (_) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
final empty = SpriteThumbnailAxis.empty(url);
|
||||
if (_disposed) return empty;
|
||||
_cacheAxis(empty);
|
||||
notifyListeners();
|
||||
return empty;
|
||||
}
|
||||
|
||||
void _cacheAxis(SpriteThumbnailAxis axis) {
|
||||
_axisCache[axis.vttUrl] = _CachedAxis(
|
||||
axis: axis,
|
||||
expiresAt: _now().add(cacheTtl),
|
||||
);
|
||||
}
|
||||
|
||||
SpriteThumbnailAxis? _readAxis(String url) {
|
||||
final cached = _axisCache[url];
|
||||
if (cached == null) return null;
|
||||
if (cached.expiresAt.isAfter(_now())) return cached.axis;
|
||||
_axisCache.remove(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
List<SpriteThumbnailCue> _prefetchCues(
|
||||
SpriteThumbnailAxis axis,
|
||||
Duration position,
|
||||
) {
|
||||
final current = selectSpriteThumbnailCue(axis.cues, position);
|
||||
if (current == null) return const [];
|
||||
final index = axis.cues.indexOf(current);
|
||||
final candidates = <SpriteThumbnailCue>[current];
|
||||
for (final direction in const [1, -1]) {
|
||||
for (var step = 1; step < axis.cues.length; step++) {
|
||||
final neighborIndex = index + direction * step;
|
||||
if (neighborIndex < 0 || neighborIndex >= axis.cues.length) break;
|
||||
final neighbor = axis.cues[neighborIndex];
|
||||
if (neighbor.spriteFile != current.spriteFile) {
|
||||
candidates.add(neighbor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (candidates.length > 1) break;
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Future<_CachedSprite?> _ensureSprite(
|
||||
SpriteThumbnailAxis axis,
|
||||
SpriteThumbnailCue cue,
|
||||
) {
|
||||
final cached = _readSprite(cue.spriteFile);
|
||||
if (cached != null) return Future.value(cached);
|
||||
if (_failedSprites.contains(cue.spriteFile)) return Future.value();
|
||||
final existing = _spriteRequests[cue.spriteFile];
|
||||
if (existing != null) return existing;
|
||||
final generation = _axisGeneration;
|
||||
final request = _loadSprite(axis, cue, generation);
|
||||
_spriteRequests[cue.spriteFile] = request;
|
||||
return request.whenComplete(() {
|
||||
_spriteRequests.remove(cue.spriteFile);
|
||||
});
|
||||
}
|
||||
|
||||
Future<_CachedSprite?> _loadSprite(
|
||||
SpriteThumbnailAxis axis,
|
||||
SpriteThumbnailCue cue,
|
||||
int generation,
|
||||
) async {
|
||||
SpriteThumbnailDecodedImage? decoded;
|
||||
try {
|
||||
decoded = await _loader.loadSprite(axis.resolveSpriteUri(cue.spriteFile));
|
||||
} catch (_) {
|
||||
decoded = null;
|
||||
}
|
||||
if (decoded == null) {
|
||||
_failedSprites.add(cue.spriteFile);
|
||||
if (!_disposed) notifyListeners();
|
||||
return null;
|
||||
}
|
||||
if (_disposed || generation != _axisGeneration) {
|
||||
decoded.image.dispose();
|
||||
return null;
|
||||
}
|
||||
final cached = _CachedSprite(decoded);
|
||||
_putSprite(cue.spriteFile, cached);
|
||||
notifyListeners();
|
||||
return cached;
|
||||
}
|
||||
|
||||
_CachedSprite? _readSprite(String key) {
|
||||
final value = _spriteCache.remove(key);
|
||||
if (value == null) return null;
|
||||
_spriteCache[key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
void _putSprite(String key, _CachedSprite value) {
|
||||
_spriteCache.remove(key)?.decoded.image.dispose();
|
||||
_spriteCache[key] = value;
|
||||
while (_spriteCache.length > spriteImageCacheCapacity) {
|
||||
final oldest = _spriteCache.remove(_spriteCache.keys.first);
|
||||
oldest?.decoded.image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
ui.Rect? _scaledSourceRect(_CachedSprite cached, ui.Rect source) {
|
||||
final scaleX = cached.decoded.image.width / cached.decoded.originalWidth;
|
||||
final scaleY = cached.decoded.image.height / cached.decoded.originalHeight;
|
||||
final scaled =
|
||||
ui.Rect.fromLTWH(
|
||||
source.left * scaleX,
|
||||
source.top * scaleY,
|
||||
source.width * scaleX,
|
||||
source.height * scaleY,
|
||||
).intersect(
|
||||
ui.Rect.fromLTWH(
|
||||
0,
|
||||
0,
|
||||
cached.decoded.image.width.toDouble(),
|
||||
cached.decoded.image.height.toDouble(),
|
||||
),
|
||||
);
|
||||
return scaled.isEmpty ? null : scaled;
|
||||
}
|
||||
|
||||
void _clearSprites() {
|
||||
for (final cached in _spriteCache.values) {
|
||||
cached.decoded.image.dispose();
|
||||
}
|
||||
_spriteCache.clear();
|
||||
_spriteRequests.clear();
|
||||
_failedSprites.clear();
|
||||
}
|
||||
|
||||
void _clearAll() {
|
||||
_axisGeneration++;
|
||||
_activeAxisUrl = null;
|
||||
_axisCache.clear();
|
||||
_axisRequests.clear();
|
||||
_clearSprites();
|
||||
}
|
||||
|
||||
String? _normalizeUrl(String? url) {
|
||||
final trimmed = url?.trim() ?? '';
|
||||
if (trimmed.isEmpty) return null;
|
||||
return AppConfig.mediaUrl(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
final RegExp _timingPattern = RegExp(
|
||||
r'^(\d{2,}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2,}:\d{2}:\d{2}\.\d{3})',
|
||||
);
|
||||
final RegExp _cueLinkPattern = RegExp(r'^(\S+?)#xywh=(.+)$');
|
||||
final RegExp _xywhPattern = RegExp(r'^(\d+),(\d+),(\d+),(\d+)$');
|
||||
|
||||
SpriteThumbnailCue? _parseCueBlock(String block) {
|
||||
final lines = block
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.where((line) => line.isNotEmpty)
|
||||
.toList();
|
||||
final timingIndex = lines.indexWhere(_timingPattern.hasMatch);
|
||||
if (timingIndex < 0 || timingIndex + 1 >= lines.length) return null;
|
||||
final timing = _timingPattern.firstMatch(lines[timingIndex]);
|
||||
if (timing == null) return null;
|
||||
final link = _cueLinkPattern.firstMatch(lines[timingIndex + 1]);
|
||||
if (link == null) return null;
|
||||
final start = _parseTimestamp(timing.group(1)!);
|
||||
final end = _parseTimestamp(timing.group(2)!);
|
||||
if (start == null || end == null || end <= start) return null;
|
||||
final segments = Uri.tryParse(link.group(1)!)?.pathSegments;
|
||||
final file = segments == null || segments.isEmpty ? null : segments.last;
|
||||
final xywh = _xywhPattern.firstMatch(link.group(2)!);
|
||||
if (file == null || file.isEmpty || xywh == null) return null;
|
||||
final rect = ui.Rect.fromLTWH(
|
||||
double.parse(xywh.group(1)!),
|
||||
double.parse(xywh.group(2)!),
|
||||
double.parse(xywh.group(3)!),
|
||||
double.parse(xywh.group(4)!),
|
||||
);
|
||||
if (rect.isEmpty) return null;
|
||||
return SpriteThumbnailCue(
|
||||
start: start,
|
||||
end: end,
|
||||
spriteFile: file,
|
||||
sourceRect: rect,
|
||||
);
|
||||
}
|
||||
|
||||
Duration? _parseTimestamp(String value) {
|
||||
final parts = value.split(':');
|
||||
if (parts.length != 3) return null;
|
||||
final secondsParts = parts[2].split('.');
|
||||
if (secondsParts.length != 2) return null;
|
||||
final hours = int.tryParse(parts[0]);
|
||||
final minutes = int.tryParse(parts[1]);
|
||||
final seconds = int.tryParse(secondsParts[0]);
|
||||
final milliseconds = int.tryParse(secondsParts[1]);
|
||||
if ([hours, minutes, seconds, milliseconds].contains(null)) return null;
|
||||
return Duration(
|
||||
hours: hours!,
|
||||
minutes: minutes!,
|
||||
seconds: seconds!,
|
||||
milliseconds: milliseconds!,
|
||||
);
|
||||
}
|
||||
|
||||
class _CachedAxis {
|
||||
const _CachedAxis({required this.axis, required this.expiresAt});
|
||||
|
||||
final SpriteThumbnailAxis axis;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
|
||||
class _CachedSprite {
|
||||
const _CachedSprite(this.decoded);
|
||||
|
||||
final SpriteThumbnailDecodedImage decoded;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/repositories/catalog_repository.dart';
|
||||
|
||||
/// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。
|
||||
class TheaterFeedController extends ChangeNotifier {
|
||||
TheaterFeedController(
|
||||
this._repository, {
|
||||
this.requestTimeout = defaultRequestTimeout,
|
||||
});
|
||||
|
||||
static const int pageSize = 20;
|
||||
static const Duration defaultRequestTimeout = Duration(seconds: 10);
|
||||
|
||||
final CatalogRepository _repository;
|
||||
final Duration requestTimeout;
|
||||
final Map<String?, _CategoryFeedState> _states = {};
|
||||
String? _categoryId;
|
||||
bool _initialized = false;
|
||||
bool _disposed = false;
|
||||
|
||||
String? get categoryId => _categoryId;
|
||||
String? get lastError => _state(_categoryId).lastError;
|
||||
List<Drama> get dramas => _state(_categoryId).dramas;
|
||||
String? get cursor => _state(_categoryId).cursor;
|
||||
bool get hasMore => _state(_categoryId).hasMore;
|
||||
bool get loaded => _state(_categoryId).loaded;
|
||||
bool get loadingMore => _state(_categoryId).loadingMore;
|
||||
bool get loadingFirstPage => !loaded && _state(_categoryId).loading;
|
||||
|
||||
/// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。
|
||||
Future<void> select(String? next) async {
|
||||
if (_initialized && next == _categoryId) return;
|
||||
_categoryId = next;
|
||||
_initialized = true;
|
||||
_notify();
|
||||
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
if (!state.loading) {
|
||||
unawaited(_refresh(next, keepData: true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.loading) {
|
||||
final inFlight = state.inFlight;
|
||||
if (inFlight != null) await inFlight;
|
||||
return;
|
||||
}
|
||||
await _refresh(next, keepData: false);
|
||||
}
|
||||
|
||||
/// 刷新当前分类;失败时保留已有列表。
|
||||
Future<bool> refreshCurrent() async {
|
||||
final state = _state(_categoryId);
|
||||
if (state.loading) {
|
||||
return await state.inFlight ?? false;
|
||||
}
|
||||
return _refresh(_categoryId, keepData: true);
|
||||
}
|
||||
|
||||
/// 加载当前分类下一页并去重追加。
|
||||
Future<void> loadMore() async {
|
||||
final categoryId = _categoryId;
|
||||
final state = _state(categoryId);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final sequence = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notifyIfCurrent(categoryId);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(
|
||||
categoryId: categoryId,
|
||||
cursor: state.cursor,
|
||||
size: pageSize,
|
||||
)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(categoryId, sequence)) return;
|
||||
final seen = state.dramas.map((drama) => drama.id).toSet();
|
||||
final appended = page.dramas
|
||||
.where((drama) => seen.add(drama.id))
|
||||
.toList();
|
||||
state
|
||||
..dramas = List.unmodifiable([...state.dramas, ...appended])
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..lastError = null;
|
||||
} catch (error) {
|
||||
if (_acceptSequence(categoryId, sequence)) {
|
||||
state.lastError = error.toString();
|
||||
}
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
_notifyIfCurrent(categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refresh(String? categoryId, {required bool keepData}) {
|
||||
final state = _state(categoryId);
|
||||
final operation = _performRefresh(categoryId, keepData: keepData);
|
||||
state.inFlight = operation;
|
||||
unawaited(_clearInFlight(state, operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh(
|
||||
String? categoryId, {
|
||||
required bool keepData,
|
||||
}) async {
|
||||
final state = _state(categoryId);
|
||||
final sequence = ++state.requestSeq;
|
||||
state
|
||||
..loading = true
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(categoryId);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(categoryId: categoryId, size: pageSize)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(categoryId, sequence)) return false;
|
||||
state
|
||||
..dramas = List.unmodifiable(page.dramas)
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(categoryId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_acceptSequence(categoryId, sequence)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = error.toString();
|
||||
_notifyIfCurrent(categoryId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearInFlight(
|
||||
_CategoryFeedState state,
|
||||
Future<bool> operation,
|
||||
) async {
|
||||
await operation;
|
||||
if (identical(state.inFlight, operation)) state.inFlight = null;
|
||||
}
|
||||
|
||||
_CategoryFeedState _state(String? categoryId) =>
|
||||
_states.putIfAbsent(categoryId, _CategoryFeedState.new);
|
||||
|
||||
bool _acceptSequence(String? categoryId, int sequence) =>
|
||||
!_disposed && _state(categoryId).requestSeq == sequence;
|
||||
|
||||
void _notifyIfCurrent(String? categoryId) {
|
||||
if (!_disposed && categoryId == _categoryId) _notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
String? lastError;
|
||||
int requestSeq = 0;
|
||||
Future<bool>? inFlight;
|
||||
}
|
||||
@@ -61,6 +61,9 @@ class VideoPlayerPort implements PlayerPort {
|
||||
if (!_disposed && controller.value.isInitialized) await controller.pause();
|
||||
}
|
||||
|
||||
/// 跳转到指定播放位置。
|
||||
Future<void> seekTo(Duration position) => controller.seekTo(position);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -11,41 +11,53 @@ import '../repositories/watch_history_repository.dart';
|
||||
|
||||
/// 独立观看记录页状态。
|
||||
///
|
||||
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
|
||||
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
|
||||
class WatchRecordsController extends ChangeNotifier {
|
||||
factory WatchRecordsController({
|
||||
required WatchHistoryRepository history,
|
||||
required CatalogRepository catalog,
|
||||
int limit = defaultLimit,
|
||||
}) => WatchRecordsController._(history, catalog, limit);
|
||||
int pageSize = defaultPageSize,
|
||||
}) => WatchRecordsController._(history, catalog, pageSize);
|
||||
|
||||
WatchRecordsController._(this._history, this._catalog, this.limit);
|
||||
WatchRecordsController._(this._history, this._catalog, this.pageSize);
|
||||
|
||||
static const int defaultLimit = 20;
|
||||
static const int defaultPageSize = 20;
|
||||
static const int detailConcurrency = 4;
|
||||
|
||||
final WatchHistoryRepository _history;
|
||||
final CatalogRepository _catalog;
|
||||
final int limit;
|
||||
final int pageSize;
|
||||
final Map<String, Drama> _detailCache = {};
|
||||
|
||||
List<WatchRecord> records = const [];
|
||||
int total = 0;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
bool hasMore = false;
|
||||
bool clearing = false;
|
||||
String? error;
|
||||
String? loadMoreError;
|
||||
String? actionError;
|
||||
int _pageIndex = 0;
|
||||
int _requestSeq = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
|
||||
Future<void> load() async {
|
||||
final request = ++_requestSeq;
|
||||
loading = true;
|
||||
loadingMore = false;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
actionError = null;
|
||||
_notify();
|
||||
try {
|
||||
final recent = await _history.recent(limit: limit);
|
||||
if (_disposed) return;
|
||||
records = _uniqueRecords(recent);
|
||||
final page = await _history.recent(pageIndex: 1, pageSize: pageSize);
|
||||
if (_disposed || request != _requestSeq) return;
|
||||
records = _uniqueRecords(page.list);
|
||||
total = page.total;
|
||||
_pageIndex = 1;
|
||||
hasMore = page.list.isNotEmpty && records.length < total;
|
||||
_notify();
|
||||
await _hydrate(records.map((record) => record.dramaId).toList());
|
||||
} catch (failure, stackTrace) {
|
||||
@@ -57,17 +69,54 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] recent failed',
|
||||
);
|
||||
if (!_disposed) {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
error = failure is ApiException ? failure.message : 'progressError';
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed) {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loading = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加下一页;刷新、加载中或无更多时忽略。
|
||||
Future<void> loadMore() async {
|
||||
if (loading || loadingMore || !hasMore) return;
|
||||
final request = ++_requestSeq;
|
||||
loadingMore = true;
|
||||
loadMoreError = null;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _history.recent(
|
||||
pageIndex: _pageIndex + 1,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
if (_disposed || request != _requestSeq) return;
|
||||
final known = records.map((record) => record.dramaId).toSet();
|
||||
final appended = _uniqueRecords(
|
||||
page.list,
|
||||
).where((record) => known.add(record.dramaId)).toList(growable: false);
|
||||
records = [...records, ...appended];
|
||||
total = page.total;
|
||||
_pageIndex += 1;
|
||||
hasMore = page.list.isNotEmpty && records.length < total;
|
||||
_notify();
|
||||
await _hydrate(appended.map((record) => record.dramaId).toList());
|
||||
} catch (failure) {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loadMoreError = failure is ApiException
|
||||
? failure.message
|
||||
: 'progressError';
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loadingMore = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 只重试一条详情失败记录。
|
||||
Future<void> retryDetail(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
@@ -81,12 +130,23 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
|
||||
Future<Drama?> detailForOpen(String dramaId) async {
|
||||
final cached = _detailCache[dramaId];
|
||||
if (cached != null) return cached;
|
||||
await _loadDetail(dramaId);
|
||||
_notify();
|
||||
return _detailCache[dramaId];
|
||||
}
|
||||
|
||||
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return true;
|
||||
final removed = records[index];
|
||||
records = [...records]..removeAt(index);
|
||||
total = total > 0 ? total - 1 : 0;
|
||||
hasMore = records.length < total;
|
||||
actionError = null;
|
||||
_notify();
|
||||
try {
|
||||
@@ -96,6 +156,8 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
final next = [...records];
|
||||
next.insert(index > next.length ? next.length : index, removed);
|
||||
records = next;
|
||||
total += 1;
|
||||
hasMore = records.length < total;
|
||||
actionError = 'recordDeleteFailed';
|
||||
return false;
|
||||
} finally {
|
||||
@@ -106,8 +168,15 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
/// 清空全部记录,先更新 UI,失败恢复原列表。
|
||||
Future<bool> clear() async {
|
||||
if (records.isEmpty) return true;
|
||||
_requestSeq += 1;
|
||||
final previous = records;
|
||||
final previousTotal = total;
|
||||
final previousPageIndex = _pageIndex;
|
||||
records = const [];
|
||||
total = 0;
|
||||
hasMore = false;
|
||||
_pageIndex = 0;
|
||||
loadingMore = false;
|
||||
clearing = true;
|
||||
actionError = null;
|
||||
_notify();
|
||||
@@ -116,6 +185,9 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
return true;
|
||||
} catch (_) {
|
||||
records = previous;
|
||||
total = previousTotal;
|
||||
_pageIndex = previousPageIndex;
|
||||
hasMore = records.length < total;
|
||||
actionError = 'recordClearFailed';
|
||||
return false;
|
||||
} finally {
|
||||
@@ -138,11 +210,12 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _hydrate(List<String> dramaIds) async {
|
||||
for (final id in dramaIds) {
|
||||
final missingTitles = dramaIds.where(_hasMissingTitle).toSet().toList();
|
||||
for (final id in missingTitles) {
|
||||
if (_detailCache.containsKey(id)) await _loadDetail(id);
|
||||
}
|
||||
final queue = [
|
||||
for (final id in dramaIds)
|
||||
for (final id in missingTitles)
|
||||
if (!_detailCache.containsKey(id)) id,
|
||||
];
|
||||
final workerCount = queue.length < detailConcurrency
|
||||
@@ -159,9 +232,15 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _loadDetail(String dramaId) async {
|
||||
bool _hasMissingTitle(String dramaId) {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return;
|
||||
if (index < 0) return false;
|
||||
return (records[index].progress.dramaTitle?.trim() ?? '').isEmpty;
|
||||
}
|
||||
|
||||
Future<Drama?> _loadDetail(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return null;
|
||||
final cached = _detailCache[dramaId];
|
||||
if (cached != null) {
|
||||
_replaceAt(
|
||||
@@ -172,14 +251,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
detailFailed: false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
final detail = await _catalog.fetchDrama(dramaId);
|
||||
if (_disposed) return;
|
||||
if (_disposed) return null;
|
||||
_detailCache[dramaId] = detail;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return;
|
||||
if (current < 0) return detail;
|
||||
_replaceAt(
|
||||
current,
|
||||
records[current].copyWith(
|
||||
@@ -188,6 +267,7 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
detailFailed: false,
|
||||
),
|
||||
);
|
||||
return detail;
|
||||
} catch (failure, stackTrace) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||
@@ -197,13 +277,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
if (_disposed) return;
|
||||
if (_disposed) return null;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return;
|
||||
if (current < 0) return null;
|
||||
_replaceAt(
|
||||
current,
|
||||
records[current].copyWith(detailLoading: false, detailFailed: true),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -0,0 +1,17 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 无位移的短促淡入转场。
|
||||
class FadePageRoute<T> extends PageRouteBuilder<T> {
|
||||
FadePageRoute({required WidgetBuilder builder, super.settings})
|
||||
: super(
|
||||
transitionDuration: const Duration(milliseconds: 220),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (context, _, _) => builder(context),
|
||||
transitionsBuilder: (context, animation, _, child) => FadeTransition(
|
||||
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ abstract final class AppDimens {
|
||||
static const double previewCardTitle = 13;
|
||||
static const double previewSectionTitle = 14;
|
||||
static const double chipFont = 10;
|
||||
static const double navLabel = 10;
|
||||
static const double navLabel = 12;
|
||||
|
||||
static const double buttonHeight = 48;
|
||||
static const double primaryButtonHeight = 52;
|
||||
@@ -45,8 +45,8 @@ abstract final class AppDimens {
|
||||
static const double tapTarget = 44;
|
||||
static const double contentHeaderHeight = 46;
|
||||
|
||||
static const double navHeight = 64;
|
||||
static const double navIcon = 20;
|
||||
static const double navHeight = 68;
|
||||
static const double navIcon = 24;
|
||||
static const double progressHeight = 3;
|
||||
static const double progressRadius = 2;
|
||||
static const double progressLineHeight = 3;
|
||||
|
||||
@@ -43,37 +43,35 @@ class AppBottomNav extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bar = Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: dark ? Colors.black : backgroundColor.withValues(alpha: 0.95),
|
||||
border: Border(
|
||||
top: BorderSide(color: dark ? Colors.white12 : AppColors.border),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
_NavDestination(
|
||||
item: item,
|
||||
unselectedColor: dark ? Colors.white70 : AppColors.navUnselected,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (dark) {
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: SafeArea(top: false, child: bar),
|
||||
);
|
||||
}
|
||||
return ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: dark
|
||||
? const Color(0xE6000000)
|
||||
: backgroundColor.withValues(alpha: 0.95),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: dark ? Colors.white12 : AppColors.border,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
_NavDestination(
|
||||
item: item,
|
||||
unselectedColor: dark
|
||||
? Colors.white70
|
||||
: AppColors.navUnselected,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(top: false, child: bar),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -109,7 +107,7 @@ class _NavDestination extends StatelessWidget {
|
||||
color: item.selected ? AppColors.blue : unselectedColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
|
||||
@@ -9,10 +9,10 @@ import '../theme/app_colors.dart';
|
||||
|
||||
const double _maxPullDistance = 96;
|
||||
const double _refreshThreshold = 56;
|
||||
const double _indicatorHeight = 64;
|
||||
const double _indicatorHeight = 56;
|
||||
const double _indicatorMaxOffset = 10;
|
||||
const Duration _arrowTurnDuration = Duration(milliseconds: 160);
|
||||
const Duration _retractDuration = Duration(milliseconds: 200);
|
||||
const Duration _settleDuration = Duration(milliseconds: 200);
|
||||
|
||||
enum _PullState { idle, pull, armed, refreshing }
|
||||
|
||||
@@ -26,7 +26,7 @@ class AppPullToRefresh extends StatefulWidget {
|
||||
});
|
||||
|
||||
static const AlwaysScrollableScrollPhysics physics =
|
||||
AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());
|
||||
AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics());
|
||||
|
||||
final Widget child;
|
||||
final Future<void> Function() onRefresh;
|
||||
@@ -36,13 +36,31 @@ class AppPullToRefresh extends StatefulWidget {
|
||||
State<AppPullToRefresh> createState() => _AppPullToRefreshState();
|
||||
}
|
||||
|
||||
class _AppPullToRefreshState extends State<AppPullToRefresh> {
|
||||
class _AppPullToRefreshState extends State<AppPullToRefresh>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _offsetController;
|
||||
_PullState _state = _PullState.idle;
|
||||
double _pullDistance = 0;
|
||||
bool _refreshing = false;
|
||||
bool _retracting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_offsetController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _settleDuration,
|
||||
upperBound: _maxPullDistance,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_offsetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _onScroll(ScrollNotification notification) {
|
||||
if (notification.depth != 0 || _refreshing) return false;
|
||||
if (notification.depth != 0 || _refreshing || _retracting) return false;
|
||||
if (notification is ScrollStartNotification) {
|
||||
if (notification.dragDetails != null) _setPullDistance(0);
|
||||
} else if (notification is ScrollUpdateNotification) {
|
||||
@@ -53,7 +71,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh> {
|
||||
}
|
||||
} else if (notification is OverscrollNotification) {
|
||||
if (notification.dragDetails != null) {
|
||||
_setPullDistance(_pullDistance - notification.overscroll);
|
||||
_setPullDistance(_offsetController.value - notification.overscroll);
|
||||
}
|
||||
} else if (notification is ScrollEndNotification) {
|
||||
_release();
|
||||
@@ -68,75 +86,104 @@ class _AppPullToRefreshState extends State<AppPullToRefresh> {
|
||||
: distance > 0
|
||||
? _PullState.pull
|
||||
: _PullState.idle;
|
||||
if (distance == _pullDistance && state == _state) return;
|
||||
setState(() {
|
||||
_pullDistance = distance;
|
||||
_state = state;
|
||||
});
|
||||
_offsetController.value = distance;
|
||||
if (state != _state) setState(() => _state = state);
|
||||
}
|
||||
|
||||
void _release() {
|
||||
if (_refreshing || _retracting) return;
|
||||
if (_state == _PullState.armed) {
|
||||
unawaited(_refresh());
|
||||
} else if (_state == _PullState.pull) {
|
||||
setState(() {
|
||||
_pullDistance = 0;
|
||||
_state = _PullState.idle;
|
||||
});
|
||||
unawaited(_retract());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retract() async {
|
||||
if (_retracting) return;
|
||||
_retracting = true;
|
||||
await _animateOffsetTo(0);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_state = _PullState.idle;
|
||||
_retracting = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
setState(() => _state = _PullState.refreshing);
|
||||
final refreshFuture = Future<void>.sync(widget.onRefresh);
|
||||
try {
|
||||
await widget.onRefresh();
|
||||
await refreshFuture;
|
||||
} finally {
|
||||
await Future<void>.delayed(_retractDuration);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pullDistance = 0;
|
||||
_state = _PullState.idle;
|
||||
_refreshing = false;
|
||||
});
|
||||
await _animateOffsetTo(0);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_state = _PullState.idle;
|
||||
_refreshing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _animateOffsetTo(double target) async {
|
||||
try {
|
||||
await _offsetController
|
||||
.animateTo(target, duration: _settleDuration, curve: Curves.easeOut)
|
||||
.orCancel;
|
||||
} on TickerCanceled {
|
||||
// The overlay can be removed while the refresh future is still pending.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Listener(
|
||||
onPointerUp: (_) => _release(),
|
||||
onPointerCancel: (_) => _release(),
|
||||
child: widget.child,
|
||||
),
|
||||
if (_state != _PullState.idle)
|
||||
Positioned(
|
||||
top: widget.edgeOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(child: _indicatorLayer(context)),
|
||||
),
|
||||
],
|
||||
child: AnimatedBuilder(
|
||||
animation: _offsetController,
|
||||
child: Listener(
|
||||
onPointerUp: (_) => _release(),
|
||||
onPointerCancel: (_) => _release(),
|
||||
child: widget.child,
|
||||
),
|
||||
builder: (context, child) {
|
||||
final offset = _offsetController.value;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Transform.translate(
|
||||
key: const Key('pull-refresh-content'),
|
||||
offset: Offset(0, offset),
|
||||
child: IgnorePointer(
|
||||
ignoring: _refreshing || _retracting,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (_state != _PullState.idle && offset > 0)
|
||||
Positioned(
|
||||
top: widget.edgeOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(child: _indicatorLayer(context, offset)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _indicatorLayer(BuildContext context) {
|
||||
final progress = (_pullDistance / _refreshThreshold).clamp(0.0, 1.0);
|
||||
final opacity = _state == _PullState.refreshing
|
||||
? 1.0
|
||||
: progress.clamp(0.0, 1.0);
|
||||
Widget _indicatorLayer(BuildContext context, double offset) {
|
||||
final progress = (offset / _refreshThreshold).clamp(0.0, 1.0);
|
||||
return SizedBox(
|
||||
height: _indicatorHeight,
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
opacity: progress,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _indicatorMaxOffset * progress),
|
||||
child: Align(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const Key _toastKey = Key('centered-toast');
|
||||
const Duration _fadeDuration = Duration(milliseconds: 200);
|
||||
const Duration _visibleDuration = Duration(seconds: 2);
|
||||
const double _toastRadius = 12;
|
||||
const double _toastMaxWidth = 320;
|
||||
const double _toastHorizontalInset = 20;
|
||||
const double _toastVerticalInset = 12;
|
||||
const double _toastBackgroundOpacity = 0.78;
|
||||
const TextStyle _toastTextStyle = TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
const EdgeInsets _toastPadding = EdgeInsets.symmetric(
|
||||
horizontal: _toastHorizontalInset,
|
||||
vertical: _toastVerticalInset,
|
||||
);
|
||||
|
||||
OverlayEntry? _activeToast;
|
||||
|
||||
/// Shows a lightweight centered message and replaces any active toast.
|
||||
void showCenteredToast(BuildContext context, String message) {
|
||||
_removeActiveToast();
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => _CenteredToast(
|
||||
message: message,
|
||||
onDismissed: () {
|
||||
if (!identical(_activeToast, entry)) return;
|
||||
_activeToast = null;
|
||||
if (entry.mounted) entry.remove();
|
||||
},
|
||||
),
|
||||
);
|
||||
_activeToast = entry;
|
||||
overlay.insert(entry);
|
||||
}
|
||||
|
||||
void _removeActiveToast() {
|
||||
final entry = _activeToast;
|
||||
_activeToast = null;
|
||||
if (entry != null && entry.mounted) entry.remove();
|
||||
}
|
||||
|
||||
class _CenteredToast extends StatefulWidget {
|
||||
const _CenteredToast({required this.message, required this.onDismissed});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onDismissed;
|
||||
|
||||
@override
|
||||
State<_CenteredToast> createState() => _CenteredToastState();
|
||||
}
|
||||
|
||||
class _CenteredToastState extends State<_CenteredToast>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _opacity = AnimationController(
|
||||
vsync: this,
|
||||
duration: _fadeDuration,
|
||||
);
|
||||
Timer? _dismissTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_opacity.forward();
|
||||
_dismissTimer = Timer(_visibleDuration, _dismiss);
|
||||
}
|
||||
|
||||
Future<void> _dismiss() async {
|
||||
try {
|
||||
await _opacity.reverse();
|
||||
} on TickerCanceled {
|
||||
return;
|
||||
}
|
||||
if (mounted) widget.onDismissed();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dismissTimer?.cancel();
|
||||
_opacity.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: _opacity,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
key: _toastKey,
|
||||
constraints: const BoxConstraints(maxWidth: _toastMaxWidth),
|
||||
padding: _toastPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(
|
||||
alpha: _toastBackgroundOpacity,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(_toastRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: _toastTextStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models/drama.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
import 'remote_image.dart';
|
||||
|
||||
/// 收藏等账号列表复用的剧集卡片,视觉与"我的"页列表保持一致。
|
||||
class LibraryDramaTile extends StatelessWidget {
|
||||
const LibraryDramaTile({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimens.innerPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
||||
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppDimens.listTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
drama.genre.isEmpty ? drama.category : drama.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '加载更多';
|
||||
|
||||
|
||||
@@ -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": "评论发布失败,请重试。",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import 'auth_logic.dart';
|
||||
|
||||
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
|
||||
class AuthBinding {
|
||||
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/plain_page_scaffold.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import 'widgets/drama_grid.dart';
|
||||
|
||||
/// 从剧场分类九宫格进入的分类剧目列表。
|
||||
class CategoryDramasPage extends StatelessWidget {
|
||||
const CategoryDramasPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.dramas,
|
||||
this.onOpen,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Drama> dramas;
|
||||
final ValueChanged<Drama>? onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Theme(
|
||||
data: AppTheme.light(),
|
||||
child: PlainPageScaffold(
|
||||
pageKey: const Key('category-dramas-page'),
|
||||
backKey: const Key('category-dramas-back'),
|
||||
title: title,
|
||||
child: dramas.isEmpty
|
||||
? EmptyState(
|
||||
key: const Key('category-dramas-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.noCategoryDramas,
|
||||
subtitle: l10n.emptyHint,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 28),
|
||||
child: DramaGrid(
|
||||
l10n: l10n,
|
||||
dramas: dramas,
|
||||
onOpen: (drama) => _open(context, drama),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _open(BuildContext context, Drama drama) {
|
||||
final callback = onOpen;
|
||||
if (callback != null) {
|
||||
callback(drama);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/content_category.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const double _gridPadding = 14;
|
||||
const double _tileSpacing = 8;
|
||||
const double _tileAspectRatio = 1.1;
|
||||
const int _columns = 3;
|
||||
const List<IconData> _icons = [
|
||||
Icons.favorite_rounded,
|
||||
Icons.bolt_rounded,
|
||||
Icons.theater_comedy_rounded,
|
||||
Icons.auto_awesome_rounded,
|
||||
Icons.psychology_alt_rounded,
|
||||
Icons.local_fire_department_rounded,
|
||||
Icons.family_restroom_rounded,
|
||||
Icons.work_history_rounded,
|
||||
];
|
||||
|
||||
/// 剧场顶部分类九宫格。
|
||||
class CategoryGrid extends StatelessWidget {
|
||||
const CategoryGrid({
|
||||
super.key,
|
||||
required this.categories,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<ContentCategory> categories;
|
||||
final ValueChanged<ContentCategory> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (categories.isEmpty) return const SizedBox.shrink();
|
||||
return GridView.builder(
|
||||
key: const Key('discover-category-grid'),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: _gridPadding),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _columns,
|
||||
mainAxisSpacing: _tileSpacing,
|
||||
crossAxisSpacing: _tileSpacing,
|
||||
childAspectRatio: _tileAspectRatio,
|
||||
),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
return _CategoryTile(
|
||||
category: category,
|
||||
icon: _icons[index % _icons.length],
|
||||
onTap: () => onTap(category),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryTile extends StatelessWidget {
|
||||
const _CategoryTile({
|
||||
required this.category,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ContentCategory category;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
key: Key('discover-category-${category.id}'),
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blueSoft.withValues(alpha: 0.16),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: AppColors.blueSoft, size: 16),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
category.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/models/home_feed.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../ranking_logic.dart';
|
||||
|
||||
const int _topLimit = 5;
|
||||
const double _rowHeight = 68;
|
||||
const double _posterWidth = 40;
|
||||
const double _posterHeight = 54;
|
||||
const double _badgeSize = 24;
|
||||
|
||||
/// 发现页内嵌榜单区块:热播/新剧切换 + 前 5 名。
|
||||
class RankingSection extends StatefulWidget {
|
||||
const RankingSection({
|
||||
super.key,
|
||||
required this.sections,
|
||||
required this.fallback,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final List<FeedSection> sections;
|
||||
final List<Drama> fallback;
|
||||
final ValueChanged<Drama> onOpen;
|
||||
|
||||
@override
|
||||
State<RankingSection> createState() => _RankingSectionState();
|
||||
}
|
||||
|
||||
class _RankingSectionState extends State<RankingSection> {
|
||||
late final RankingLogic logic = RankingLogic(
|
||||
sections: widget.sections,
|
||||
fallback: widget.fallback,
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RankingSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.sections, widget.sections) ||
|
||||
!identical(oldWidget.fallback, widget.fallback)) {
|
||||
logic.updateSources(sections: widget.sections, fallback: widget.fallback);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: logic,
|
||||
builder: (context, _) => _content(context),
|
||||
);
|
||||
|
||||
Widget _content(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entries = logic.entries.take(_topLimit).toList();
|
||||
return Container(
|
||||
key: const Key('discover-ranking-section'),
|
||||
margin: const EdgeInsets.fromLTRB(14, 16, 14, 8),
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
const SizedBox(height: 8),
|
||||
if (entries.isEmpty)
|
||||
EmptyState(
|
||||
key: const Key('ranking-empty'),
|
||||
icon: Icons.leaderboard_outlined,
|
||||
title: l10n.rankingEmptyTitle,
|
||||
subtitle: l10n.rankingEmptyBody,
|
||||
compact: true,
|
||||
)
|
||||
else
|
||||
for (final entry in entries) _row(entry, l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_fire_department_rounded,
|
||||
color: AppColors.blue,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.rankingTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
_boardChip(
|
||||
key: const Key('ranking-tab-hot'),
|
||||
label: l10n.rankingHot,
|
||||
board: RankingBoard.hot,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_boardChip(
|
||||
key: const Key('ranking-tab-new'),
|
||||
label: l10n.rankingNew,
|
||||
board: RankingBoard.newReleases,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _boardChip({
|
||||
required Key key,
|
||||
required String label,
|
||||
required RankingBoard board,
|
||||
}) {
|
||||
final selected = logic.board == board;
|
||||
return Material(
|
||||
key: key,
|
||||
color: selected ? AppColors.blue : AppColors.card,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => logic.select(board),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(RankingEntry entry, AppLocalizations l10n) {
|
||||
return _RankRow(
|
||||
entry: entry,
|
||||
subtitle: _subtitle(entry.drama, l10n),
|
||||
onTap: () => widget.onOpen(entry.drama),
|
||||
);
|
||||
}
|
||||
|
||||
String _subtitle(Drama drama, AppLocalizations l10n) {
|
||||
final genre = drama.genre.isNotEmpty ? drama.genre : drama.category;
|
||||
final count = l10n.episodeCount(drama.episodeCount);
|
||||
return genre.isEmpty ? count : '$genre · $count';
|
||||
}
|
||||
}
|
||||
|
||||
class _RankRow extends StatelessWidget {
|
||||
const _RankRow({
|
||||
required this.entry,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final RankingEntry entry;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final drama = entry.drama;
|
||||
return Material(
|
||||
key: Key('ranking-entry-${drama.id}'),
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(height: _rowHeight, child: _content(drama)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(Drama drama) {
|
||||
return Row(
|
||||
children: [
|
||||
_rankBadge(entry.rank),
|
||||
const SizedBox(width: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
width: _posterWidth,
|
||||
height: _posterHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _text(drama)),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.subtle,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _text(Drama drama) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.subtle, fontSize: 9),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rankBadge(int rank) {
|
||||
final top = rank <= 3;
|
||||
return Container(
|
||||
width: _badgeSize,
|
||||
height: _badgeSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: top ? AppColors.gold.withValues(alpha: 0.16) : AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: TextStyle(
|
||||
color: top ? AppColors.gold : AppColors.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +38,7 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
int? continueIndex;
|
||||
WatchProgress? progress;
|
||||
String? progressError;
|
||||
int _favoriteCountDelta = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -70,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;
|
||||
@@ -118,8 +139,26 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
await loadContinue();
|
||||
}
|
||||
|
||||
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
|
||||
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
|
||||
/// 详情页展示用的收藏数;没有服务端计数时继续保持缺省展示。
|
||||
int? get adjustedFavoriteCount {
|
||||
final base = drama.favoriteCount;
|
||||
if (base == null) return null;
|
||||
final adjusted = base + _favoriteCountDelta;
|
||||
return adjusted < 0 ? 0 : adjusted;
|
||||
}
|
||||
|
||||
/// 收藏切换:乐观状态由控制器负责回滚,这里只在成功后同步计数。
|
||||
Future<bool> toggleFavorite() async {
|
||||
final wasSaved = favorites.isSaved(drama.id);
|
||||
final ok = await favorites.toggle(drama.id, card: drama);
|
||||
if (!ok) return false;
|
||||
final nowSaved = favorites.isSaved(drama.id);
|
||||
if (nowSaved != wasSaved) {
|
||||
_favoriteCountDelta += nowSaved ? 1 : -1;
|
||||
notify();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
||||
@@ -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(
|
||||
@@ -142,7 +150,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: DramaStatsCard(l10n: l10n, drama: drama),
|
||||
child: DramaStatsCard(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
favoriteCount: logic.adjustedFavoriteCount,
|
||||
likeCount: statsEpisode?.likeCount,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: DramaSynopsisSection(
|
||||
|
||||
@@ -29,19 +29,28 @@ const TextStyle _synopsisStyle = TextStyle(
|
||||
|
||||
/// 详情页三项数据卡:热度、收藏与点赞,缺失值不做伪造。
|
||||
class DramaStatsCard extends StatelessWidget {
|
||||
const DramaStatsCard({super.key, required this.l10n, required this.drama});
|
||||
const DramaStatsCard({
|
||||
super.key,
|
||||
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) {
|
||||
final favorite = drama.favoriteCount == null
|
||||
final currentFavoriteCount = favoriteCount ?? drama.favoriteCount;
|
||||
final favorite = currentFavoriteCount == null
|
||||
? l10n.episodeCountLabel(drama.episodeCount)
|
||||
: '${formatInteractionCount(drama.favoriteCount!)} ${l10n.favorite}';
|
||||
final like = drama.likeCount == null
|
||||
: '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}';
|
||||
final like = likeCount == null
|
||||
? '—'
|
||||
: '${formatInteractionCount(drama.likeCount!)} ${l10n.like}';
|
||||
: '${formatInteractionCount(likeCount!)} ${l10n.like}';
|
||||
return Container(
|
||||
key: const Key('detail-stats'),
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
|
||||
@@ -16,10 +16,11 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/paged_list_footer.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/widgets/subpage_header.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../player/open_player.dart';
|
||||
import 'favorites_binding.dart';
|
||||
|
||||
const double _pageHorizontal = 14;
|
||||
@@ -33,8 +34,12 @@ const double _removeIconSize = 20;
|
||||
const double _statsFontSize = 12;
|
||||
const double _metaFontSize = 11;
|
||||
const double _titleFontSize = 15;
|
||||
const double _synopsisFontSize = 12;
|
||||
const double _synopsisLineHeight = 1.15;
|
||||
const double _synopsisGap = 4;
|
||||
const int _maxTags = 2;
|
||||
const double _emptyListHeight = 360;
|
||||
const double _loadMoreThreshold = 240;
|
||||
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
@@ -156,6 +161,15 @@ class _FavoritesBody extends StatelessWidget {
|
||||
onRemove: () => _remove(context, drama.id),
|
||||
),
|
||||
],
|
||||
PagedListFooter(
|
||||
loading: favorites.loadingMore,
|
||||
hasMore: favorites.hasMore,
|
||||
hasItems: favorites.cards.isNotEmpty,
|
||||
errorMessage: favorites.loadMoreError == null
|
||||
? null
|
||||
: errorText(l10n, favorites.loadMoreError, l10n.favoritesError),
|
||||
onRetry: favorites.loadMore,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -164,26 +178,42 @@ class _FavoritesBody extends StatelessWidget {
|
||||
FavoriteController favorites, {
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
return AppPullToRefresh(
|
||||
onRefresh: favorites.load,
|
||||
child: ListView(
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_pageHorizontal,
|
||||
4,
|
||||
_pageHorizontal,
|
||||
28,
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: AppPullToRefresh(
|
||||
onRefresh: favorites.load,
|
||||
child: ListView(
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_pageHorizontal,
|
||||
4,
|
||||
_pageHorizontal,
|
||||
28,
|
||||
),
|
||||
children: children,
|
||||
),
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _onScroll(ScrollNotification notification) {
|
||||
if (notification.depth != 0) return false;
|
||||
final reachedBottom =
|
||||
notification.metrics.extentAfter <= _loadMoreThreshold;
|
||||
if (reachedBottom &&
|
||||
(notification is ScrollUpdateNotification ||
|
||||
notification is ScrollEndNotification ||
|
||||
notification is OverscrollNotification)) {
|
||||
unawaited(favorites.loadMore());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _summary(AppLocalizations l10n) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
l10n.favoritesCount(favorites.cards.length),
|
||||
l10n.favoritesCount(favorites.total),
|
||||
key: const Key('favorites-count'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
@@ -194,9 +224,7 @@ class _FavoritesBody extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context, Drama drama) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
||||
);
|
||||
await playDramaFromCatalog(context, drama);
|
||||
}
|
||||
|
||||
Future<void> _remove(BuildContext context, String dramaId) async {
|
||||
@@ -227,6 +255,7 @@ class _FavoriteCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final tags = _tags();
|
||||
final synopsis = drama.synopsis.trim();
|
||||
return Container(
|
||||
key: ValueKey('favorite-card-${drama.id}'),
|
||||
decoration: BoxDecoration(
|
||||
@@ -320,7 +349,25 @@ class _FavoriteCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: _synopsisGap),
|
||||
Flexible(
|
||||
child: Text(
|
||||
synopsis,
|
||||
key: ValueKey(
|
||||
'favorite-synopsis-${drama.id}',
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: _synopsisFontSize,
|
||||
height: _synopsisLineHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
|
||||
/// 首页频道,只依赖目录中已有数据排序或过滤。
|
||||
enum HomeChannel { hot, free, newReleases, ranking }
|
||||
|
||||
/// 浏览型首页的本地 UI 状态。
|
||||
class HomeBrowseLogic extends ChangeNotifier {
|
||||
HomeBrowseLogic({required this.history}) {
|
||||
unawaited(refreshRecent());
|
||||
}
|
||||
|
||||
static const int recentLimit = 20;
|
||||
|
||||
final WatchHistoryRepository history;
|
||||
HomeChannel channel = HomeChannel.hot;
|
||||
List<WatchProgress> recent = const [];
|
||||
bool loadingRecent = false;
|
||||
bool _disposed = false;
|
||||
|
||||
/// 第一条未完成且存在真实播放位置的记录。
|
||||
WatchProgress? get primaryRecord {
|
||||
for (final record in recent) {
|
||||
if (!record.finished && record.hasPosition) return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 切换频道,不触发额外网络请求。
|
||||
void selectChannel(HomeChannel value) {
|
||||
if (channel == value) return;
|
||||
channel = value;
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// 重新加载续看数据;未登录时返回空列表。
|
||||
Future<void> refreshRecent() async {
|
||||
loadingRecent = true;
|
||||
_notify();
|
||||
try {
|
||||
recent = await history.recent(limit: recentLimit);
|
||||
} catch (_) {
|
||||
recent = const [];
|
||||
} finally {
|
||||
loadingRecent = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页重新可见时刷新续看数据。
|
||||
void syncPage({required bool active, required bool wasActive}) {
|
||||
if (active && !wasActive) unawaited(refreshRecent());
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../discover/widgets/feed_banner_carousel.dart';
|
||||
import '../mine/widgets/continue_watching_card.dart';
|
||||
import 'home_browse_logic.dart';
|
||||
import 'widgets/home_browse_header.dart';
|
||||
import 'widgets/home_drama_grid.dart';
|
||||
|
||||
const double _channelRowHeight = 44;
|
||||
const double _channelGap = 20;
|
||||
const double _channelUnderlineWidth = 18;
|
||||
const double _channelUnderlineHeight = 2;
|
||||
const double _channelUnderlineGap = 4;
|
||||
const double _channelFontSize = 15;
|
||||
const List<Shadow> _channelTextShadows = [
|
||||
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
|
||||
];
|
||||
|
||||
/// 浏览型首页:频道、Hero、继续观看与剧目目录。
|
||||
class HomeBrowseView extends StatefulWidget {
|
||||
const HomeBrowseView({
|
||||
super.key,
|
||||
required this.active,
|
||||
required this.onSearch,
|
||||
required this.onProfile,
|
||||
required this.onTheater,
|
||||
required this.onHistory,
|
||||
required this.onOpenDrama,
|
||||
required this.onOpenContinue,
|
||||
});
|
||||
|
||||
final bool active;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onProfile;
|
||||
final VoidCallback onTheater;
|
||||
final VoidCallback onHistory;
|
||||
final Future<void> Function(Drama drama) onOpenDrama;
|
||||
final Future<void> Function(Drama drama, WatchProgress record) onOpenContinue;
|
||||
|
||||
@override
|
||||
State<HomeBrowseView> createState() => _HomeBrowseViewState();
|
||||
}
|
||||
|
||||
class _HomeBrowseViewState extends State<HomeBrowseView> {
|
||||
late final HomeBrowseLogic logic;
|
||||
final ScrollController _scroll = ScrollController();
|
||||
bool _channelsPinned = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = HomeBrowseLogic(history: context.read<WatchHistoryRepository>());
|
||||
_scroll.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HomeBrowseView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
logic.syncPage(active: widget.active, wasActive: oldWidget.active);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.removeListener(_onScroll);
|
||||
_scroll.dispose();
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 滚过 hero 后频道行切成吸顶态,未过阈值时仍叠在轮播图上。
|
||||
void _onScroll() {
|
||||
if (!_scroll.hasClients) return;
|
||||
final pinned =
|
||||
_scroll.offset >= AppDimens.heroBannerHeight - _channelRowHeight;
|
||||
if (pinned != _channelsPinned) {
|
||||
setState(() => _channelsPinned = pinned);
|
||||
}
|
||||
}
|
||||
|
||||
/// 频道行只横向滚动,纵向拖动转交给页面列表,避免挡住上滑。
|
||||
void _scrollPageBy(DragUpdateDetails details) {
|
||||
if (!_scroll.hasClients) return;
|
||||
final position = _scroll.position;
|
||||
_scroll.jumpTo(
|
||||
(_scroll.offset - details.delta.dy)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: logic,
|
||||
builder: (context, _) => _page(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final catalog = context.watch<CatalogController>();
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
HomeBrowseHeader(
|
||||
onSearch: widget.onSearch,
|
||||
onProfile: widget.onProfile,
|
||||
),
|
||||
Expanded(child: _body(l10n, catalog)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n, CatalogController catalog) {
|
||||
final source = catalog.dramas;
|
||||
final banners = catalog.banners.isEmpty
|
||||
? _fallbackBanners(source)
|
||||
: catalog.banners;
|
||||
final hasBanner = banners.isNotEmpty;
|
||||
final pinned = !hasBanner || _channelsPinned;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('home-refresh'),
|
||||
onRefresh: () async {
|
||||
await Future.wait([catalog.load(), logic.refreshRecent()]);
|
||||
},
|
||||
child: ListView(
|
||||
key: const Key('home-browse-page'),
|
||||
controller: _scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
||||
children: _sections(l10n, catalog),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _channelBar(
|
||||
l10n,
|
||||
pinned: pinned,
|
||||
overImage: hasBanner && !pinned,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _channelBar(
|
||||
AppLocalizations l10n, {
|
||||
required bool pinned,
|
||||
required bool overImage,
|
||||
}) {
|
||||
final content = GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: _scrollPageBy,
|
||||
child: _channels(l10n, overImage: overImage),
|
||||
);
|
||||
if (!pinned) return content;
|
||||
return DecoratedBox(
|
||||
key: const Key('home-channels-pinned'),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.ink,
|
||||
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _sections(AppLocalizations l10n, CatalogController catalog) {
|
||||
final source = catalog.dramas;
|
||||
final dramas = _channelDramas(source, logic.channel);
|
||||
final continueItem = _continueItem(logic.recent, catalog.dramas);
|
||||
final banners = catalog.banners.isEmpty
|
||||
? _fallbackBanners(source)
|
||||
: catalog.banners;
|
||||
return [
|
||||
if (banners.isNotEmpty) _hero(banners, source),
|
||||
if (continueItem != null) ...[
|
||||
_continueHeader(l10n),
|
||||
ContinueWatchingCard(
|
||||
cardKey: const Key('home-continue-watching'),
|
||||
playButtonKey: Key('home-continue-play-${continueItem.drama.id}'),
|
||||
progressKey: Key('home-continue-progress-${continueItem.drama.id}'),
|
||||
showRemaining: true,
|
||||
drama: continueItem.drama,
|
||||
record: continueItem.record,
|
||||
onResume: () => unawaited(
|
||||
widget.onOpenContinue(continueItem.drama, continueItem.record),
|
||||
),
|
||||
),
|
||||
],
|
||||
_channelHeader(l10n),
|
||||
if (dramas.isEmpty)
|
||||
_empty()
|
||||
else
|
||||
HomeDramaGrid(
|
||||
l10n: l10n,
|
||||
dramas: dramas,
|
||||
onOpen: (drama) => unawaited(widget.onOpenDrama(drama)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<FeedBanner> _fallbackBanners(List<Drama> dramas) {
|
||||
if (dramas.isEmpty) return const [];
|
||||
final drama = dramas.first;
|
||||
return [
|
||||
FeedBanner(
|
||||
id: 'home-fallback-${drama.id}',
|
||||
imageUrl: drama.posterUrl,
|
||||
linkType: 'DRAMA',
|
||||
linkValue: drama.id,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _continueHeader(AppLocalizations l10n) => _sectionHeader(
|
||||
l10n.continueWatching,
|
||||
actionKey: const Key('home-continue-all'),
|
||||
actionLabel: l10n.seeAll,
|
||||
onAction: widget.onHistory,
|
||||
);
|
||||
|
||||
Widget _channelHeader(AppLocalizations l10n) => _sectionHeader(
|
||||
_channelTitle(l10n),
|
||||
actionKey: const Key('home-popular-more'),
|
||||
actionLabel: l10n.seeAll,
|
||||
onAction: widget.onTheater,
|
||||
);
|
||||
|
||||
Widget _empty() => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl),
|
||||
child: EmptyState(
|
||||
key: const Key('home-browse-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: AppLocalizations.of(context)!.noStories,
|
||||
subtitle: AppLocalizations.of(context)!.emptyHint,
|
||||
compact: true,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _channels(AppLocalizations l10n, {bool overImage = false}) {
|
||||
final entries = [
|
||||
(HomeChannel.hot, l10n.hot),
|
||||
(HomeChannel.free, l10n.free),
|
||||
(HomeChannel.newReleases, l10n.newDramas),
|
||||
(HomeChannel.ranking, l10n.rankingTab),
|
||||
];
|
||||
return SizedBox(
|
||||
key: const Key('home-channels'),
|
||||
height: _channelRowHeight,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.pageHorizontal,
|
||||
),
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: _channelGap),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final selected = logic.channel == entry.$1;
|
||||
return _channelItem(
|
||||
entry.$1,
|
||||
entry.$2,
|
||||
selected: selected,
|
||||
overImage: overImage,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _channelItem(
|
||||
HomeChannel channel,
|
||||
String label, {
|
||||
required bool selected,
|
||||
required bool overImage,
|
||||
}) {
|
||||
final color = selected
|
||||
? AppColors.blue
|
||||
: (overImage ? Colors.white70 : AppColors.muted);
|
||||
final underlineColor = selected ? AppColors.blue : Colors.transparent;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
key: Key('home-channel-${channel.name}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => logic.selectChannel(channel),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: _channelFontSize,
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
shadows: overImage ? _channelTextShadows : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _channelUnderlineGap),
|
||||
Container(
|
||||
key: Key('home-channel-underline-${channel.name}'),
|
||||
width: _channelUnderlineWidth,
|
||||
height: _channelUnderlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: underlineColor,
|
||||
borderRadius: BorderRadius.circular(
|
||||
_channelUnderlineHeight / 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hero(List<FeedBanner> banners, List<Drama> dramas) {
|
||||
return FeedBannerCarousel(
|
||||
key: const Key('home-hero-banner'),
|
||||
banners: banners,
|
||||
active: widget.active,
|
||||
flush: true,
|
||||
dramaFor: (banner) => _dramaForBanner(banner, dramas),
|
||||
onOpen: (banner) {
|
||||
final drama = _dramaForBanner(banner, dramas);
|
||||
if (drama != null) unawaited(widget.onOpenDrama(drama));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(
|
||||
String title, {
|
||||
required Key actionKey,
|
||||
required String actionLabel,
|
||||
required VoidCallback onAction,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.pageHorizontal,
|
||||
AppDimens.spaceLg,
|
||||
AppDimens.spaceSm,
|
||||
AppDimens.spaceSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: AppDimens.sectionTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: actionKey,
|
||||
onPressed: onAction,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
|
||||
),
|
||||
child: Text(actionLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _channelTitle(AppLocalizations l10n) {
|
||||
return switch (logic.channel) {
|
||||
HomeChannel.hot => l10n.trendingMicroDramas,
|
||||
HomeChannel.free => l10n.free,
|
||||
HomeChannel.newReleases => l10n.newDramas,
|
||||
HomeChannel.ranking => l10n.rankingTitle,
|
||||
};
|
||||
}
|
||||
|
||||
List<Drama> _channelDramas(List<Drama> source, HomeChannel channel) {
|
||||
final dramas = List<Drama>.of(source);
|
||||
switch (channel) {
|
||||
case HomeChannel.hot:
|
||||
dramas.sort((a, b) {
|
||||
final hot = (b.isHot ? 1 : 0).compareTo(a.isHot ? 1 : 0);
|
||||
return hot != 0 ? hot : b.viewCount.compareTo(a.viewCount);
|
||||
});
|
||||
case HomeChannel.free:
|
||||
return dramas.where(_isFree).toList();
|
||||
case HomeChannel.newReleases:
|
||||
return dramas;
|
||||
case HomeChannel.ranking:
|
||||
dramas.sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
||||
}
|
||||
return dramas;
|
||||
}
|
||||
|
||||
bool _isFree(Drama drama) {
|
||||
return drama.freeEpisodes > 0 ||
|
||||
drama.episodes.any((episode) => episode.isFree);
|
||||
}
|
||||
|
||||
Drama? _dramaForBanner(FeedBanner banner, List<Drama> dramas) {
|
||||
final id = banner.dramaId;
|
||||
if (id == null) return null;
|
||||
for (final drama in dramas) {
|
||||
if (drama.id == id) return drama;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
({Drama drama, WatchProgress record})? _continueItem(
|
||||
List<WatchProgress> records,
|
||||
List<Drama> dramas,
|
||||
) {
|
||||
for (final record in records) {
|
||||
if (record.finished || !record.hasPosition) continue;
|
||||
for (final drama in dramas) {
|
||||
if (drama.id == record.dramaId) {
|
||||
return (drama: drama, record: record);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 首页顶栏的三个内容频道。
|
||||
enum HomeFeedChannel {
|
||||
anime('ANIME'),
|
||||
live('LIVE'),
|
||||
recommend(null);
|
||||
|
||||
const HomeFeedChannel(this.apiType);
|
||||
|
||||
final String? apiType;
|
||||
|
||||
List<String> get apiTypes => apiType == null ? const [] : <String>[apiType!];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../discover/discover_types.dart';
|
||||
@@ -22,6 +23,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
bool drawerOpen = false;
|
||||
bool detailOpen = false;
|
||||
bool videoImmersive = false;
|
||||
bool _dramaNotifyScheduled = false;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -120,7 +122,17 @@ class HomeLogic extends ChangeNotifier {
|
||||
|
||||
void onDramaChanged(Drama drama) {
|
||||
currentDrama = drama;
|
||||
notify();
|
||||
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (_dramaNotifyScheduled || _disposed) return;
|
||||
_dramaNotifyScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_dramaNotifyScheduled = false;
|
||||
if (!_disposed) notify();
|
||||
});
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
|
||||
void onImmersiveChanged(bool value) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../notices/notices_page.dart';
|
||||
|
||||
const double _searchHeight = 36;
|
||||
|
||||
/// 首页固定顶栏:搜索、通知与头像入口。
|
||||
class HomeBrowseHeader extends StatelessWidget {
|
||||
const HomeBrowseHeader({
|
||||
super.key,
|
||||
required this.onSearch,
|
||||
required this.onProfile,
|
||||
});
|
||||
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onProfile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return SizedBox(
|
||||
height: AppDimens.contentHeaderHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.pageHorizontal,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _search(l10n, onSearch)),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
_notifications(context, l10n),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
_profile(l10n),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _search(AppLocalizations l10n, VoidCallback onTap) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: l10n.search,
|
||||
child: InkWell(
|
||||
key: const Key('home-search'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
child: Container(
|
||||
height: _searchHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.input,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.search_rounded,
|
||||
color: AppColors.subtle,
|
||||
size: 17,
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.searchDiscover,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.subtle,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notifications(BuildContext context, AppLocalizations l10n) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: l10n.notices,
|
||||
child: InkWell(
|
||||
key: const Key('home-notifications'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => const NoticesPage())),
|
||||
child: Container(
|
||||
width: AppDimens.tapTarget,
|
||||
height: AppDimens.tapTarget,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.card,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.notifications_none_rounded,
|
||||
color: AppColors.onSurface,
|
||||
size: AppDimens.accountIcon,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _profile(AppLocalizations l10n) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: l10n.myTab,
|
||||
child: InkWell(
|
||||
key: const Key('home-profile'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onProfile,
|
||||
child: Container(
|
||||
width: AppDimens.tapTarget,
|
||||
height: AppDimens.tapTarget,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.card,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppColors.onSurface,
|
||||
size: AppDimens.accountIcon,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 播放页快捷菜单:当前剧、发现、追剧与历史。
|
||||
class HomeMenuSheet extends StatelessWidget {
|
||||
const HomeMenuSheet({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onOpenSeries,
|
||||
required this.onDiscover,
|
||||
required this.onHistory,
|
||||
required this.onWatchlist,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback onOpenSeries;
|
||||
final VoidCallback onDiscover;
|
||||
final VoidCallback onHistory;
|
||||
final VoidCallback onWatchlist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
void closeThen(VoidCallback action) {
|
||||
Navigator.pop(context);
|
||||
action();
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DramaSummary(
|
||||
drama: drama,
|
||||
l10n: l10n,
|
||||
onOpenSeries: () => closeThen(onOpenSeries),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_quickAccess(l10n, closeThen),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _quickAccess(
|
||||
AppLocalizations l10n,
|
||||
void Function(VoidCallback) closeThen,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.quickAccess,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_item(
|
||||
key: const Key('drawer-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
label: l10n.discoverTab,
|
||||
onTap: () => closeThen(onDiscover),
|
||||
),
|
||||
_item(
|
||||
key: const Key('drawer-watchlist'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
label: l10n.watchlist,
|
||||
onTap: () => closeThen(onWatchlist),
|
||||
),
|
||||
_item(
|
||||
key: const Key('drawer-history'),
|
||||
icon: Icons.history_rounded,
|
||||
label: l10n.history,
|
||||
onTap: () => closeThen(onHistory),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _item({
|
||||
required Key key,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _MenuTile(key: key, icon: icon, label: label, onTap: onTap),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DramaSummary extends StatelessWidget {
|
||||
const _DramaSummary({
|
||||
required this.drama,
|
||||
required this.l10n,
|
||||
required this.onOpenSeries,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final AppLocalizations l10n;
|
||||
final VoidCallback onOpenSeries;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: RemoteImage(drama.posterUrl, width: 64, height: 86),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _details()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _details() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_button(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _button() {
|
||||
return SizedBox(
|
||||
height: 34,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('drawer-open-series'),
|
||||
onPressed: onOpenSeries,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)),
|
||||
textStyle: const TextStyle(fontSize: 11),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 15),
|
||||
label: Text(l10n.exploreSeries),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuTile extends StatelessWidget {
|
||||
const _MenuTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.quick,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 58,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: AppColors.secondary, size: 16),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/models/watch_progress.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 「我的」页唯一保留的续看入口。
|
||||
class ContinueWatchingCard extends StatelessWidget {
|
||||
const ContinueWatchingCard({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.record,
|
||||
required this.onResume,
|
||||
this.cardKey = const Key('mine-continue-watching'),
|
||||
this.playButtonKey = const Key('mine-continue-play'),
|
||||
this.progressKey,
|
||||
this.showRemaining = false,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final WatchProgress record;
|
||||
final VoidCallback onResume;
|
||||
final Key cardKey;
|
||||
final Key playButtonKey;
|
||||
final Key? progressKey;
|
||||
final bool showRemaining;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
key: cardKey,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 14),
|
||||
padding: const EdgeInsets.all(9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: RemoteImage(drama.posterUrl, width: 50, height: 64),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(child: _info(l10n)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
height: 28,
|
||||
child: FilledButton(
|
||||
key: playButtonKey,
|
||||
onPressed: onResume,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 10),
|
||||
),
|
||||
child: Text(l10n.resume),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _info(AppLocalizations l10n) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.continueWatching,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_progressText(l10n),
|
||||
key: progressKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _progressText(AppLocalizations l10n) {
|
||||
final episode = drama.episodes.where((item) => item.id == record.episodeId);
|
||||
if (showRemaining) {
|
||||
final number = episode.isEmpty ? 1 : episode.first.number;
|
||||
final remaining = record.duration - record.position;
|
||||
final minutes = remaining.inSeconds <= 0
|
||||
? 0
|
||||
: (remaining.inSeconds / Duration.secondsPerMinute).ceil();
|
||||
return l10n.episodeRemaining(number, minutes);
|
||||
}
|
||||
final label = episode.isEmpty
|
||||
? l10n.episodeCount(drama.episodeCount)
|
||||
: l10n.episodeOfTotal(episode.first.number, drama.episodeCount);
|
||||
return '$label · ${_clock(record.position)} / ${_clock(record.duration)}';
|
||||
}
|
||||
}
|
||||
|
||||
String _clock(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';
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/download_controller.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/navigation/fade_page_route.dart';
|
||||
import 'player_view.dart';
|
||||
|
||||
/// 目录卡片统一入口:补齐详情后直接进入全屏播放页。
|
||||
@@ -29,7 +30,7 @@ Future<void> playDramaFromCatalog(
|
||||
? 0
|
||||
: playable.episodes.indexWhere((episode) => episode.id == episodeId);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
FadePageRoute<void>(
|
||||
builder: (_) => PlayerPage(
|
||||
drama: playable,
|
||||
initialIndex: index < 0 ? 0 : index,
|
||||
|
||||