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 | ||
|
|
d2174c5b74 | ||
|
|
7871b0cfbb | ||
|
|
c76b388814 | ||
|
|
9b31d83e39 | ||
|
|
1199db760f | ||
|
|
8f60046ec3 | ||
|
|
210ccc97cb | ||
|
|
699fd427b8 | ||
|
|
911f13f464 | ||
|
|
4c8ae7bcec | ||
|
|
51e94029e4 | ||
|
|
0aa5a99a22 | ||
|
|
9eec9ed77a | ||
|
|
983e99a4b6 | ||
|
|
7c2fd090e0 | ||
|
|
a756faca6c | ||
|
|
da549492b0 | ||
|
|
398dc3f8b4 |
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: 4.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 69 B |
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 69 B |
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
</shape>
|
||||
@@ -1,9 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -5,6 +5,9 @@
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_transparent</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,12 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_transparent</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -2,6 +2,5 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<color name="splash_background">#FF100C0A</color>
|
||||
<color name="ic_launcher_background">#FFF1E5</color>
|
||||
</resources>
|
||||
<color name="splash_background">#1F1B16</color>
|
||||
</resources>
|
||||
@@ -1,12 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
|
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 |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 61 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "background.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 69 B |
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 37 KiB |
@@ -1,5 +0,0 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
@@ -9,9 +9,27 @@
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.06274509803921569" green="0.047058823529411764" blue="0.0392156862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
@@ -19,4 +37,8 @@
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="1152" height="1152"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -41,15 +41,28 @@ 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';
|
||||
import 'core/navigation/app_route_observer.dart';
|
||||
import 'core/platform/haptics.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/theme/app_colors.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'pages/home/home_view.dart';
|
||||
|
||||
/// 全局系统栏样式:浅暖导航栏、关闭 Android 对比遮罩。
|
||||
final SystemUiOverlayStyle appSystemOverlayStyle = SystemUiOverlayStyle.dark
|
||||
.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: AppColors.ink,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
);
|
||||
|
||||
class LumaDramaApp extends StatefulWidget {
|
||||
const LumaDramaApp({
|
||||
super.key,
|
||||
@@ -60,6 +73,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
this.watchHistoryRepository,
|
||||
this.favoriteRepository,
|
||||
this.playbackRepository,
|
||||
this.playbackPositionCache,
|
||||
this.commentRepository,
|
||||
this.interactionRepository,
|
||||
this.noticeRepository,
|
||||
@@ -79,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;
|
||||
@@ -134,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,
|
||||
);
|
||||
@@ -224,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),
|
||||
@@ -256,6 +276,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
navigatorObservers: [appRouteObserver],
|
||||
localeResolutionCallback: (deviceLocale, _) =>
|
||||
resolveAppLocale(deviceLocale),
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -263,11 +284,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.light(),
|
||||
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: AppColors.bar,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
value: appSystemOverlayStyle,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
home: const HomePage(),
|
||||
|
||||
@@ -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` 映射到开发机,
|
||||
@@ -28,11 +27,17 @@ class AppConfig {
|
||||
|
||||
static String _runtimeBaseUrl = '';
|
||||
|
||||
static Future<void>? _ready;
|
||||
|
||||
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。
|
||||
static Future<void> get ready => _ready ?? Future<void>.value();
|
||||
static set ready(Future<void> value) => _ready = value;
|
||||
|
||||
static const Duration _probeTimeout = Duration(seconds: 2);
|
||||
static const List<String> _probeCandidates = [
|
||||
lanBaseUrl,
|
||||
devHostBaseUrl,
|
||||
publicBaseUrl,
|
||||
if (devHostBaseUrl != publicBaseUrl) publicBaseUrl,
|
||||
];
|
||||
|
||||
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`.
|
||||
@@ -79,16 +84,29 @@ 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)
|
||||
if (candidate != _runtimeBaseUrl) candidate,
|
||||
];
|
||||
for (final candidate in candidates) {
|
||||
final result = probe == null
|
||||
? await _probeBaseUrl(candidate)
|
||||
: await _runInjectedProbe(candidate, probe);
|
||||
final results = await Future.wait([
|
||||
for (final candidate in candidates)
|
||||
probe == null
|
||||
? _probeBaseUrl(candidate)
|
||||
: _runInjectedProbe(candidate, probe),
|
||||
]);
|
||||
for (var index = 0; index < candidates.length; index++) {
|
||||
final candidate = candidates[index];
|
||||
final result = results[index];
|
||||
if (kDebugMode) {
|
||||
final status = result.ok
|
||||
? 'ok'
|
||||
|
||||
@@ -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';
|
||||
@@ -21,7 +24,7 @@ class ApiClient {
|
||||
this.tokenRefresher,
|
||||
this.onUnauthorized,
|
||||
this.deviceIdReader,
|
||||
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
|
||||
}) : _fixedBaseUrl = baseUrl {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: this.baseUrl,
|
||||
@@ -39,15 +42,19 @@ class ApiClient {
|
||||
deviceIdReader: deviceIdReader,
|
||||
),
|
||||
const EnvelopeInterceptor(),
|
||||
NetworkLogInterceptor(),
|
||||
]);
|
||||
if (adapter != null) {
|
||||
_dio.httpClientAdapter = adapter;
|
||||
}
|
||||
}
|
||||
|
||||
final String baseUrl;
|
||||
final String? _fixedBaseUrl;
|
||||
late final Dio _dio;
|
||||
|
||||
/// 当前请求使用的服务地址;未显式指定时跟随启动探测结果。
|
||||
String get baseUrl => _fixedBaseUrl ?? AppConfig.apiBaseUrl;
|
||||
|
||||
/// 取当前 access token(未登录返回 null)
|
||||
final String? Function()? accessTokenReader;
|
||||
|
||||
@@ -110,6 +117,7 @@ class ApiClient {
|
||||
bool retried = false,
|
||||
bool skipAuthRetry = false,
|
||||
}) async {
|
||||
_syncBaseUrl();
|
||||
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
|
||||
try {
|
||||
final response = await _dio.request<Object?>(
|
||||
@@ -150,5 +158,10 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
void _syncBaseUrl() {
|
||||
if (_fixedBaseUrl != null) return;
|
||||
_dio.options.baseUrl = AppConfig.apiBaseUrl;
|
||||
}
|
||||
|
||||
void close() => _dio.close(force: true);
|
||||
}
|
||||
@@ -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
|
||||
|
||||