merge: 合并进度条画面预览
This commit is contained in:
commit
89a94f85c2
15 files changed
+1493
-77
No files matched your search
@@ -5,8 +5,13 @@ package com.stormg.lumadrama
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -15,12 +20,19 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
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() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val shareRequestCode = 9041
|
private val shareRequestCode = 9041
|
||||||
|
private val thumbnailExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||||
|
private val thumbnailHandler = Handler(Looper.getMainLooper())
|
||||||
private var pendingShare: MethodChannel.Result? = null
|
private var pendingShare: MethodChannel.Result? = null
|
||||||
private var pipChannel: MethodChannel? = null
|
private var pipChannel: MethodChannel? = null
|
||||||
private var keyboardChannel: MethodChannel? = null
|
private var keyboardChannel: MethodChannel? = null
|
||||||
|
private var thumbnailChannel: MethodChannel? = null
|
||||||
private var imeInset = 0
|
private var imeInset = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -86,6 +98,47 @@ class MainActivity : FlutterActivity() {
|
|||||||
emitImeInset(inset)
|
emitImeInset(inset)
|
||||||
result.success(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
|
val decorView = window.decorView
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
|
||||||
emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
|
emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
|
||||||
@@ -106,6 +159,59 @@ class MainActivity : FlutterActivity() {
|
|||||||
keyboardChannel?.invokeMethod("insetsChanged", 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) {
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||||
pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode)
|
pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode)
|
||||||
@@ -124,6 +230,16 @@ class MainActivity : FlutterActivity() {
|
|||||||
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
|
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
|
||||||
keyboardChannel?.setMethodCallHandler(null)
|
keyboardChannel?.setMethodCallHandler(null)
|
||||||
keyboardChannel = null
|
keyboardChannel = null
|
||||||
|
thumbnailChannel?.setMethodCallHandler(null)
|
||||||
|
thumbnailChannel = null
|
||||||
|
thumbnailHandler.removeCallbacksAndMessages(null)
|
||||||
|
thumbnailExecutor.shutdownNow()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val thumbnailMaxWidth = 240
|
||||||
|
private const val thumbnailJpegQuality = 70
|
||||||
|
private const val thumbnailTimeoutMs = 1500L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 真机验收。
|
||||||
@@ -426,3 +426,56 @@ $ flutter test --no-pub
|
|||||||
解读:短缓冲不闪、持续缓冲仍显示白色高光;未初始化/入口/返回/重建路径
|
解读:短缓冲不闪、持续缓冲仍显示白色高光;未初始化/入口/返回/重建路径
|
||||||
均无多余 loading,重建顺序为 initialize→seek→play。全量静态分析零告警,
|
均无多余 loading,重建顺序为 initialize→seek→play。全量静态分析零告警,
|
||||||
593 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
|
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 真机验收。
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
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
|
/// Keeps the system LaunchScreen as the only splash. Flutter would otherwise
|
||||||
/// instantiate that storyboard again and the icon appears a second time.
|
/// instantiate that storyboard again and the icon appears a second time.
|
||||||
@@ -16,6 +35,11 @@ final class RunnerFlutterViewController: FlutterViewController {
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
|
private let thumbnailQueue = DispatchQueue(
|
||||||
|
label: "com.stormg.lumadrama.thumbnail",
|
||||||
|
qos: .userInitiated
|
||||||
|
)
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
@@ -44,5 +68,66 @@ final class RunnerFlutterViewController: FlutterViewController {
|
|||||||
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
|
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
|
||||||
controller.present(activity, animated: true)
|
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,365 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// 原生取帧所需的播放源信息。
|
||||||
|
class PlaybackThumbnailSource {
|
||||||
|
const PlaybackThumbnailSource({
|
||||||
|
this.localPath = '',
|
||||||
|
this.remoteUrl = '',
|
||||||
|
this.headers = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
final String localPath;
|
||||||
|
final String remoteUrl;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
|
||||||
|
bool get isAvailable => localPath.isNotEmpty || remoteUrl.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 episodeId 读取当前可用播放源,确保签名地址刷新后立即生效。
|
||||||
|
typedef PlaybackThumbnailSourceResolver =
|
||||||
|
PlaybackThumbnailSource? Function(String episodeId);
|
||||||
|
|
||||||
|
/// 传给原生取帧层的单次请求。
|
||||||
|
class PlaybackThumbnailFrameRequest {
|
||||||
|
const PlaybackThumbnailFrameRequest({
|
||||||
|
required this.episodeId,
|
||||||
|
required this.position,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String episodeId;
|
||||||
|
final Duration position;
|
||||||
|
final PlaybackThumbnailSource source;
|
||||||
|
|
||||||
|
Map<String, Object> toChannelArguments() => {
|
||||||
|
'episodeId': episodeId,
|
||||||
|
'timeMs': position.inMilliseconds,
|
||||||
|
'localPath': source.localPath,
|
||||||
|
'url': source.remoteUrl,
|
||||||
|
'headers': source.headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可注入的原生取帧接口。
|
||||||
|
abstract interface class PlaybackThumbnailFrameLoader {
|
||||||
|
Future<ByteData?> load(PlaybackThumbnailFrameRequest request);
|
||||||
|
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认通过平台通道从本地播放器原生层取帧。
|
||||||
|
class MethodChannelPlaybackThumbnailFrameLoader
|
||||||
|
implements PlaybackThumbnailFrameLoader {
|
||||||
|
MethodChannelPlaybackThumbnailFrameLoader({MethodChannel? channel})
|
||||||
|
: _channel =
|
||||||
|
channel ?? const MethodChannel('com.stormg.lumadrama/thumbnail');
|
||||||
|
|
||||||
|
final MethodChannel _channel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async {
|
||||||
|
final bytes = await _channel.invokeMethod<Uint8List>(
|
||||||
|
'frame',
|
||||||
|
request.toChannelArguments(),
|
||||||
|
);
|
||||||
|
return bytes == null ? null : ByteData.sublistView(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每秒一个 bucket,拖过同一秒时复用同一张缩略图。
|
||||||
|
int playbackThumbnailBucket(Duration position) {
|
||||||
|
final milliseconds = position.inMilliseconds;
|
||||||
|
if (milliseconds <= 0) return 0;
|
||||||
|
return milliseconds ~/ Duration.millisecondsPerSecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放器缩略图单飞、LRU 缓存与拖动预取调度。
|
||||||
|
class PlaybackThumbnailService {
|
||||||
|
PlaybackThumbnailService({
|
||||||
|
PlaybackThumbnailFrameLoader? frameLoader,
|
||||||
|
this.sourceResolver,
|
||||||
|
this.requestTimeout = const Duration(milliseconds: 1500),
|
||||||
|
this.prefetchDelay = const Duration(milliseconds: 120),
|
||||||
|
this.cacheCapacity = defaultCacheCapacity,
|
||||||
|
}) : _frameLoader =
|
||||||
|
frameLoader ?? MethodChannelPlaybackThumbnailFrameLoader(),
|
||||||
|
assert(cacheCapacity > 0);
|
||||||
|
|
||||||
|
static const int defaultCacheCapacity = 24;
|
||||||
|
|
||||||
|
final PlaybackThumbnailFrameLoader _frameLoader;
|
||||||
|
final PlaybackThumbnailSourceResolver? sourceResolver;
|
||||||
|
final Duration requestTimeout;
|
||||||
|
final Duration prefetchDelay;
|
||||||
|
final int cacheCapacity;
|
||||||
|
final LinkedHashMap<String, ByteData> _cache = LinkedHashMap();
|
||||||
|
final LinkedHashMap<String, bool> _unavailable = LinkedHashMap();
|
||||||
|
|
||||||
|
_ThumbnailRequest? _inFlight;
|
||||||
|
Future<ByteData?>? _inFlightFuture;
|
||||||
|
_ThumbnailRequest? _queued;
|
||||||
|
Timer? _prefetchTimer;
|
||||||
|
String? _wantedKey;
|
||||||
|
String? _latestEpisodeId;
|
||||||
|
int? _latestBucket;
|
||||||
|
int _prefetchDirection = 1;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
int get cacheSize => _cache.length;
|
||||||
|
|
||||||
|
/// 取指定剧集、时间点的缩略图。失败或超时统一返回 null。
|
||||||
|
Future<ByteData?> thumbnail(String episodeId, Duration position) {
|
||||||
|
if (_disposed) return Future<ByteData?>.value();
|
||||||
|
final bucket = playbackThumbnailBucket(position);
|
||||||
|
final key = _key(episodeId, bucket);
|
||||||
|
_wantedKey = key;
|
||||||
|
_noteRequestPosition(episodeId, bucket);
|
||||||
|
_prefetchTimer?.cancel();
|
||||||
|
_prefetchTimer = null;
|
||||||
|
_dropQueued();
|
||||||
|
|
||||||
|
final cached = _readCache(key);
|
||||||
|
if (cached != null) {
|
||||||
|
_schedulePrefetch(key);
|
||||||
|
return Future<ByteData?>.value(cached);
|
||||||
|
}
|
||||||
|
if (_inFlight?.key == key && _inFlightFuture != null) {
|
||||||
|
_schedulePrefetch(key);
|
||||||
|
return _inFlightFuture!;
|
||||||
|
}
|
||||||
|
if (_unavailable.containsKey(key)) {
|
||||||
|
_promoteUnavailable(key);
|
||||||
|
_schedulePrefetch(key);
|
||||||
|
return Future<ByteData?>.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
final request = _ThumbnailRequest(
|
||||||
|
key: key,
|
||||||
|
episodeId: episodeId,
|
||||||
|
bucket: bucket,
|
||||||
|
);
|
||||||
|
_schedulePrefetch(key);
|
||||||
|
if (_inFlight == null) return _start(request);
|
||||||
|
return _queue(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 松手或离开拖动后调用;只取消尚未开始的请求。
|
||||||
|
void cancelPending() {
|
||||||
|
_wantedKey = null;
|
||||||
|
_prefetchTimer?.cancel();
|
||||||
|
_prefetchTimer = null;
|
||||||
|
_dropQueued();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页面销毁时清空帧缓存和所有待执行请求。
|
||||||
|
void clear() {
|
||||||
|
cancelPending();
|
||||||
|
_cache.clear();
|
||||||
|
_unavailable.clear();
|
||||||
|
_latestEpisodeId = null;
|
||||||
|
_latestBucket = null;
|
||||||
|
_prefetchDirection = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
clear();
|
||||||
|
_frameLoader.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ByteData?> _start(_ThumbnailRequest request) {
|
||||||
|
final completer = Completer<ByteData?>();
|
||||||
|
_inFlight = request;
|
||||||
|
_inFlightFuture = completer.future;
|
||||||
|
unawaited(_run(request, completer));
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ByteData?> _queue(_ThumbnailRequest request) {
|
||||||
|
final completer = Completer<ByteData?>();
|
||||||
|
_queued = request.withCompleter(completer);
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run(
|
||||||
|
_ThumbnailRequest request,
|
||||||
|
Completer<ByteData?> completer,
|
||||||
|
) async {
|
||||||
|
ByteData? frame;
|
||||||
|
try {
|
||||||
|
frame = await _loadFrame(request);
|
||||||
|
} catch (_) {
|
||||||
|
frame = null;
|
||||||
|
}
|
||||||
|
if (frame == null) {
|
||||||
|
_putUnavailable(request.key);
|
||||||
|
} else {
|
||||||
|
_putCache(request.key, frame);
|
||||||
|
}
|
||||||
|
final deliver = _wantedKey == request.key;
|
||||||
|
_inFlight = null;
|
||||||
|
_inFlightFuture = null;
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(deliver ? frame : null);
|
||||||
|
}
|
||||||
|
_drainQueued();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ByteData?> _loadFrame(_ThumbnailRequest request) {
|
||||||
|
PlaybackThumbnailSource? source;
|
||||||
|
try {
|
||||||
|
source = sourceResolver?.call(request.episodeId);
|
||||||
|
} catch (_) {
|
||||||
|
return Future<ByteData?>.value();
|
||||||
|
}
|
||||||
|
if (source == null || !source.isAvailable) {
|
||||||
|
return Future<ByteData?>.value();
|
||||||
|
}
|
||||||
|
final frame = PlaybackThumbnailFrameRequest(
|
||||||
|
episodeId: request.episodeId,
|
||||||
|
position: Duration(seconds: request.bucket),
|
||||||
|
source: source,
|
||||||
|
);
|
||||||
|
final result = Completer<ByteData?>();
|
||||||
|
late final Timer timeout;
|
||||||
|
void finish(ByteData? value) {
|
||||||
|
if (result.isCompleted) return;
|
||||||
|
timeout.cancel();
|
||||||
|
result.complete(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = Timer(requestTimeout, () => finish(null));
|
||||||
|
try {
|
||||||
|
_frameLoader
|
||||||
|
.load(frame)
|
||||||
|
.then(finish, onError: (Object _, StackTrace _) => finish(null));
|
||||||
|
} catch (_) {
|
||||||
|
finish(null);
|
||||||
|
}
|
||||||
|
return result.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drainQueued() {
|
||||||
|
if (_disposed || _inFlight != null) return;
|
||||||
|
final queued = _queued;
|
||||||
|
_queued = null;
|
||||||
|
if (queued == null || queued.key != _wantedKey) {
|
||||||
|
queued?.completer.complete(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_inFlight = queued;
|
||||||
|
_inFlightFuture = queued.completer.future;
|
||||||
|
unawaited(_run(queued, queued.completer));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dropQueued() {
|
||||||
|
final queued = _queued;
|
||||||
|
_queued = null;
|
||||||
|
if (queued != null && !queued.completer.isCompleted) {
|
||||||
|
queued.completer.complete(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _noteRequestPosition(String episodeId, int bucket) {
|
||||||
|
if (_latestEpisodeId == episodeId && _latestBucket != null) {
|
||||||
|
if (bucket < _latestBucket!) _prefetchDirection = -1;
|
||||||
|
if (bucket > _latestBucket!) _prefetchDirection = 1;
|
||||||
|
}
|
||||||
|
_latestEpisodeId = episodeId;
|
||||||
|
_latestBucket = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _schedulePrefetch(String currentKey) {
|
||||||
|
_prefetchTimer?.cancel();
|
||||||
|
_prefetchTimer = Timer(prefetchDelay, () {
|
||||||
|
_prefetchTimer = null;
|
||||||
|
if (_disposed || _wantedKey != currentKey) return;
|
||||||
|
final episodeId = _latestEpisodeId;
|
||||||
|
final bucket = _latestBucket;
|
||||||
|
if (episodeId == null || bucket == null) return;
|
||||||
|
final neighbor = bucket + _prefetchDirection;
|
||||||
|
if (neighbor < 0) return;
|
||||||
|
final key = _key(episodeId, neighbor);
|
||||||
|
if (_cache.containsKey(key) ||
|
||||||
|
_unavailable.containsKey(key) ||
|
||||||
|
_inFlight?.key == key ||
|
||||||
|
_queued != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final request = _ThumbnailRequest(
|
||||||
|
key: key,
|
||||||
|
episodeId: episodeId,
|
||||||
|
bucket: neighbor,
|
||||||
|
);
|
||||||
|
if (_inFlight == null) {
|
||||||
|
unawaited(_start(request));
|
||||||
|
} else {
|
||||||
|
_queue(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteData? _readCache(String key) {
|
||||||
|
final value = _cache.remove(key);
|
||||||
|
if (value == null) return null;
|
||||||
|
_cache[key] = value;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _putCache(String key, ByteData value) {
|
||||||
|
_unavailable.remove(key);
|
||||||
|
_cache.remove(key);
|
||||||
|
_cache[key] = value;
|
||||||
|
while (_cache.length > cacheCapacity) {
|
||||||
|
_cache.remove(_cache.keys.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _putUnavailable(String key) {
|
||||||
|
_unavailable.remove(key);
|
||||||
|
_unavailable[key] = true;
|
||||||
|
while (_unavailable.length > cacheCapacity) {
|
||||||
|
_unavailable.remove(_unavailable.keys.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _promoteUnavailable(String key) {
|
||||||
|
if (_unavailable.remove(key) == null) return;
|
||||||
|
_unavailable[key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _key(String episodeId, int bucket) => '$episodeId:$bucket';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThumbnailRequest {
|
||||||
|
_ThumbnailRequest({
|
||||||
|
required this.key,
|
||||||
|
required this.episodeId,
|
||||||
|
required this.bucket,
|
||||||
|
Completer<ByteData?>? completer,
|
||||||
|
}) : completer = completer ?? Completer<ByteData?>();
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
final String episodeId;
|
||||||
|
final int bucket;
|
||||||
|
final Completer<ByteData?> completer;
|
||||||
|
|
||||||
|
_ThumbnailRequest withCompleter(Completer<ByteData?> value) =>
|
||||||
|
_ThumbnailRequest(
|
||||||
|
key: key,
|
||||||
|
episodeId: episodeId,
|
||||||
|
bucket: bucket,
|
||||||
|
completer: value,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/repositories/comment_repository.dart';
|
import '../../core/data/repositories/comment_repository.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
@@ -13,6 +14,7 @@ import '../../core/data/services/download_controller.dart';
|
|||||||
import '../../core/data/services/interaction_controller.dart';
|
import '../../core/data/services/interaction_controller.dart';
|
||||||
import '../../core/data/services/playback_position_cache.dart';
|
import '../../core/data/services/playback_position_cache.dart';
|
||||||
import '../../core/data/services/playback_session.dart';
|
import '../../core/data/services/playback_session.dart';
|
||||||
|
import '../../core/data/services/playback_thumbnail_service.dart';
|
||||||
import '../../core/data/services/video_player_port.dart';
|
import '../../core/data/services/video_player_port.dart';
|
||||||
import '../../core/data/services/wallet_controller.dart';
|
import '../../core/data/services/wallet_controller.dart';
|
||||||
import '../../core/formatting/error_text.dart';
|
import '../../core/formatting/error_text.dart';
|
||||||
@@ -103,6 +105,7 @@ class PlayerPage extends StatefulWidget {
|
|||||||
class _PlayerPageState extends State<PlayerPage>
|
class _PlayerPageState extends State<PlayerPage>
|
||||||
with WidgetsBindingObserver, RouteAware {
|
with WidgetsBindingObserver, RouteAware {
|
||||||
late final PlayerLogic logic;
|
late final PlayerLogic logic;
|
||||||
|
late final PlaybackThumbnailService _thumbnailService;
|
||||||
PageRoute<dynamic>? _subscribedRoute;
|
PageRoute<dynamic>? _subscribedRoute;
|
||||||
bool _chromeVisible = true;
|
bool _chromeVisible = true;
|
||||||
double _playbackSpeed = 1.0;
|
double _playbackSpeed = 1.0;
|
||||||
@@ -125,6 +128,9 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
downloads: widget.downloads,
|
downloads: widget.downloads,
|
||||||
);
|
);
|
||||||
logic.attach(widget, isMounted: () => mounted);
|
logic.attach(widget, isMounted: () => mounted);
|
||||||
|
_thumbnailService = PlaybackThumbnailService(
|
||||||
|
sourceResolver: _resolveThumbnailSource,
|
||||||
|
);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
logic.start(
|
logic.start(
|
||||||
factory: context.read<PlayerFactory>(),
|
factory: context.read<PlayerFactory>(),
|
||||||
@@ -161,6 +167,57 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
String _feedSignature(List<Drama>? dramas) =>
|
String _feedSignature(List<Drama>? dramas) =>
|
||||||
dramas == null ? '' : dramas.map((drama) => drama.id).join('|');
|
dramas == null ? '' : dramas.map((drama) => drama.id).join('|');
|
||||||
|
|
||||||
|
PlaybackThumbnailSource? _resolveThumbnailSource(String episodeId) {
|
||||||
|
final localPath = widget.downloads?.localPathFor(episodeId);
|
||||||
|
Episode? episode;
|
||||||
|
for (final item in logic.sessionEpisodes) {
|
||||||
|
if (item.id == episodeId) {
|
||||||
|
episode = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (localPath != null && localPath.isNotEmpty) {
|
||||||
|
return PlaybackThumbnailSource(localPath: localPath);
|
||||||
|
}
|
||||||
|
final rawUrl = episode?.videoUrl ?? '';
|
||||||
|
final active = logic.session.active;
|
||||||
|
final activeSource = active is VideoPlayerPort
|
||||||
|
? active.controller.dataSource
|
||||||
|
: '';
|
||||||
|
final currentEpisodeId = logic.sessionEpisodes.isEmpty
|
||||||
|
? null
|
||||||
|
: logic.session.currentEpisode.id;
|
||||||
|
final activeMatches =
|
||||||
|
currentEpisodeId == episodeId && activeSource.isNotEmpty;
|
||||||
|
if (activeMatches && activeSource.startsWith('file:')) {
|
||||||
|
try {
|
||||||
|
return PlaybackThumbnailSource(
|
||||||
|
localPath: Uri.parse(activeSource).toFilePath(),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rawUrl.startsWith('file:')) {
|
||||||
|
try {
|
||||||
|
return PlaybackThumbnailSource(
|
||||||
|
localPath: Uri.parse(rawUrl).toFilePath(),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final remoteUrl = AppConfig.mediaUrl(
|
||||||
|
rawUrl.isNotEmpty
|
||||||
|
? rawUrl
|
||||||
|
: activeMatches
|
||||||
|
? activeSource
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
if (remoteUrl.isEmpty) return null;
|
||||||
|
return PlaybackThumbnailSource(remoteUrl: remoteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -190,6 +247,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
appRouteObserver.unsubscribe(this);
|
appRouteObserver.unsubscribe(this);
|
||||||
|
_thumbnailService.dispose();
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -547,6 +605,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
onSeek: ready
|
onSeek: ready
|
||||||
? (position) => logic.seekTo(context, videoPort!, position)
|
? (position) => logic.seekTo(context, videoPort!, position)
|
||||||
: null,
|
: null,
|
||||||
|
thumbnailService: _thumbnailService,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
PlayerPlaybackOverlay(
|
PlayerPlaybackOverlay(
|
||||||
@@ -569,6 +628,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
playbackSpeed: _playbackSpeed,
|
playbackSpeed: _playbackSpeed,
|
||||||
totalEpisodes: logic.visibleIndices.length,
|
totalEpisodes: logic.visibleIndices.length,
|
||||||
boosting: _boosting,
|
boosting: _boosting,
|
||||||
|
thumbnailService: _thumbnailService,
|
||||||
),
|
),
|
||||||
actions: PlayerOverlayActions(
|
actions: PlayerOverlayActions(
|
||||||
onBack: widget.embedded
|
onBack: widget.embedded
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../../core/data/models/drama.dart';
|
import '../../../core/data/models/drama.dart';
|
||||||
|
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||||
import '../../../core/formatting/count_format.dart';
|
import '../../../core/formatting/count_format.dart';
|
||||||
import '../../../core/formatting/localized_content.dart';
|
import '../../../core/formatting/localized_content.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import 'interaction_rail.dart';
|
import 'interaction_rail.dart';
|
||||||
import 'loading_progress_track.dart';
|
import 'loading_progress_track.dart';
|
||||||
|
import 'scrub_preview_card.dart';
|
||||||
|
import 'seek_progress_bar.dart';
|
||||||
|
|
||||||
part 'home_feed_overlay_parts.dart';
|
part 'home_feed_overlay_parts.dart';
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
this.authorNote = '',
|
this.authorNote = '',
|
||||||
this.onScrubStart,
|
this.onScrubStart,
|
||||||
this.onSeek,
|
this.onSeek,
|
||||||
|
this.thumbnailService,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
@@ -86,6 +90,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onMore;
|
final VoidCallback onMore;
|
||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final ValueChanged<Duration>? onSeek;
|
final ValueChanged<Duration>? onSeek;
|
||||||
|
final PlaybackThumbnailService? thumbnailService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||||
@@ -287,7 +292,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20),
|
const Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -342,6 +351,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: _fullBarHeight,
|
height: _fullBarHeight,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -394,6 +404,9 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
hitHeight: _progressHitHeight,
|
hitHeight: _progressHitHeight,
|
||||||
onScrubStart: widget.onScrubStart,
|
onScrubStart: widget.onScrubStart,
|
||||||
onSeek: widget.onSeek,
|
onSeek: widget.onSeek,
|
||||||
|
thumbnailService: widget.thumbnailService,
|
||||||
|
episodeId: widget.episode.id,
|
||||||
|
posterUrl: widget.drama.posterUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class _FeedProgressBar extends StatefulWidget {
|
|||||||
required this.hitHeight,
|
required this.hitHeight,
|
||||||
this.onScrubStart,
|
this.onScrubStart,
|
||||||
this.onSeek,
|
this.onSeek,
|
||||||
|
this.thumbnailService,
|
||||||
|
required this.episodeId,
|
||||||
|
required this.posterUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ValueListenable<VideoPlayerValue>? controller;
|
final ValueListenable<VideoPlayerValue>? controller;
|
||||||
@@ -25,6 +28,9 @@ class _FeedProgressBar extends StatefulWidget {
|
|||||||
final double hitHeight;
|
final double hitHeight;
|
||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final ValueChanged<Duration>? onSeek;
|
final ValueChanged<Duration>? onSeek;
|
||||||
|
final PlaybackThumbnailService? thumbnailService;
|
||||||
|
final String episodeId;
|
||||||
|
final String posterUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_FeedProgressBar> createState() => _FeedProgressBarState();
|
State<_FeedProgressBar> createState() => _FeedProgressBarState();
|
||||||
@@ -51,6 +57,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
_bufferTimer?.cancel();
|
_bufferTimer?.cancel();
|
||||||
_bufferTimer = null;
|
_bufferTimer = null;
|
||||||
_buffering = false;
|
_buffering = false;
|
||||||
|
widget.thumbnailService?.cancelPending();
|
||||||
_attachSource();
|
_attachSource();
|
||||||
}
|
}
|
||||||
if (oldWidget.locked != widget.locked) {
|
if (oldWidget.locked != widget.locked) {
|
||||||
@@ -58,6 +65,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
_bufferTimer = null;
|
_bufferTimer = null;
|
||||||
_buffering = false;
|
_buffering = false;
|
||||||
if (widget.locked) {
|
if (widget.locked) {
|
||||||
|
widget.thumbnailService?.cancelPending();
|
||||||
_preview = null;
|
_preview = null;
|
||||||
} else {
|
} else {
|
||||||
_syncBufferState(notify: false);
|
_syncBufferState(notify: false);
|
||||||
@@ -69,6 +77,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_detachSource(widget.controller);
|
_detachSource(widget.controller);
|
||||||
_bufferTimer?.cancel();
|
_bufferTimer?.cancel();
|
||||||
|
if (_preview != null) widget.thumbnailService?.cancelPending();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +113,14 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
!widget.locked &&
|
!widget.locked &&
|
||||||
!loading &&
|
!loading &&
|
||||||
(source?.value.isInitialized ?? false);
|
(source?.value.isInitialized ?? false);
|
||||||
|
final video = source?.value;
|
||||||
|
final previewPosition = _preview == null || video == null
|
||||||
|
? null
|
||||||
|
: seekPositionForFraction(_preview!, video.duration);
|
||||||
|
final showPreview =
|
||||||
|
previewPosition != null &&
|
||||||
|
widget.thumbnailService != null &&
|
||||||
|
widget.episodeId.isNotEmpty;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
key: const Key('home-feed-progress'),
|
key: const Key('home-feed-progress'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
@@ -117,10 +134,28 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
? (details) => _update(details.localPosition.dx / width)
|
? (details) => _update(details.localPosition.dx / width)
|
||||||
: null,
|
: null,
|
||||||
onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null,
|
onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null,
|
||||||
onHorizontalDragCancel: interactive
|
onHorizontalDragCancel: interactive ? _cancelPreview : null,
|
||||||
? () => setState(() => _preview = null)
|
child: Stack(
|
||||||
: null,
|
clipBehavior: Clip.none,
|
||||||
child: track,
|
children: [
|
||||||
|
track,
|
||||||
|
if (showPreview)
|
||||||
|
Positioned(
|
||||||
|
left: scrubPreviewLeft(
|
||||||
|
trackWidth: width,
|
||||||
|
fraction: _preview!,
|
||||||
|
),
|
||||||
|
bottom: widget.hitHeight + 8,
|
||||||
|
child: ScrubPreviewCard(
|
||||||
|
service: widget.thumbnailService!,
|
||||||
|
episodeId: widget.episodeId,
|
||||||
|
posterUrl: widget.posterUrl,
|
||||||
|
position: previewPosition,
|
||||||
|
duration: video!.duration,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -230,6 +265,12 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
widget.thumbnailService?.cancelPending();
|
||||||
|
setState(() => _preview = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelPreview() {
|
||||||
|
widget.thumbnailService?.cancelPending();
|
||||||
setState(() => _preview = null);
|
setState(() => _preview = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../core/formatting/localized_content.dart';
|
import '../../../core/formatting/localized_content.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
@@ -264,6 +265,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
showTimes: true,
|
showTimes: true,
|
||||||
compact: true,
|
compact: true,
|
||||||
locked: data.locked,
|
locked: data.locked,
|
||||||
|
thumbnailService: data.thumbnailService,
|
||||||
|
episodeId: data.episode.id,
|
||||||
|
posterUrl: data.drama.posterUrl,
|
||||||
inactiveTrackColor: Colors.white24,
|
inactiveTrackColor: Colors.white24,
|
||||||
onScrubStart: actions.onScrubStart,
|
onScrubStart: actions.onScrubStart,
|
||||||
onSeek: actions.onSeek,
|
onSeek: actions.onSeek,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../../core/data/models/drama.dart';
|
import '../../../core/data/models/drama.dart';
|
||||||
|
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 播放浮层展示数据。
|
/// 播放浮层展示数据。
|
||||||
@@ -24,6 +25,7 @@ class PlayerOverlayData {
|
|||||||
required this.totalEpisodes,
|
required this.totalEpisodes,
|
||||||
required this.boosting,
|
required this.boosting,
|
||||||
required this.locked,
|
required this.locked,
|
||||||
|
this.thumbnailService,
|
||||||
this.authorNote = '',
|
this.authorNote = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,6 +71,9 @@ class PlayerOverlayData {
|
|||||||
/// 当前剧集是否锁定。
|
/// 当前剧集是否锁定。
|
||||||
final bool locked;
|
final bool locked;
|
||||||
|
|
||||||
|
/// 拖动进度时按当前集和时间点取帧。
|
||||||
|
final PlaybackThumbnailService? thumbnailService;
|
||||||
|
|
||||||
/// 作者声明;没有后端字段时为空。
|
/// 作者声明;没有后端字段时为空。
|
||||||
final String authorNote;
|
final String authorNote;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/remote_image.dart';
|
||||||
|
|
||||||
|
const double scrubPreviewWidth = 120;
|
||||||
|
const double scrubPreviewSafeMargin = 12;
|
||||||
|
|
||||||
|
/// 把预览卡片限制在进度条可用宽度内。
|
||||||
|
double scrubPreviewLeft({
|
||||||
|
required double trackWidth,
|
||||||
|
required double fraction,
|
||||||
|
double cardWidth = scrubPreviewWidth,
|
||||||
|
double safeMargin = scrubPreviewSafeMargin,
|
||||||
|
}) {
|
||||||
|
final maxLeft = trackWidth - cardWidth - safeMargin;
|
||||||
|
if (maxLeft < safeMargin) {
|
||||||
|
return ((trackWidth - cardWidth) / 2).clamp(0.0, trackWidth).toDouble();
|
||||||
|
}
|
||||||
|
final requested = trackWidth * fraction.clamp(0.0, 1.0) - cardWidth / 2;
|
||||||
|
return requested.clamp(safeMargin, maxLeft).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拖动进度时覆盖在轨道上方的单帧预览。
|
||||||
|
class ScrubPreviewCard extends StatefulWidget {
|
||||||
|
const ScrubPreviewCard({
|
||||||
|
super.key,
|
||||||
|
required this.service,
|
||||||
|
required this.episodeId,
|
||||||
|
required this.posterUrl,
|
||||||
|
required this.position,
|
||||||
|
required this.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PlaybackThumbnailService service;
|
||||||
|
final String episodeId;
|
||||||
|
final String posterUrl;
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ScrubPreviewCard> createState() => _ScrubPreviewCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||||
|
ByteData? _frame;
|
||||||
|
int _requestSerial = 0;
|
||||||
|
int? _requestedBucket;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_requestFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant ScrubPreviewCard oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final bucket = playbackThumbnailBucket(widget.position);
|
||||||
|
if (oldWidget.service != widget.service ||
|
||||||
|
oldWidget.episodeId != widget.episodeId ||
|
||||||
|
_requestedBucket != bucket) {
|
||||||
|
_frame = null;
|
||||||
|
_requestFrame();
|
||||||
|
} else {
|
||||||
|
// 同 bucket 内继续拖动时不重复取帧,但会重置 120ms 预取停顿计时。
|
||||||
|
unawaited(widget.service.thumbnail(widget.episodeId, widget.position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_requestSerial++;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _requestFrame() {
|
||||||
|
final bucket = playbackThumbnailBucket(widget.position);
|
||||||
|
_requestedBucket = bucket;
|
||||||
|
final serial = ++_requestSerial;
|
||||||
|
final future = widget.service.thumbnail(widget.episodeId, widget.position);
|
||||||
|
future.then((frame) {
|
||||||
|
if (!mounted || serial != _requestSerial) return;
|
||||||
|
setState(() => _frame = frame);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
key: const Key('scrub-preview-card'),
|
||||||
|
width: scrubPreviewWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.white, width: 1),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
|
color: Colors.black87,
|
||||||
|
child: Text(
|
||||||
|
'${_format(widget.position)} / ${_format(widget.duration)}',
|
||||||
|
key: const Key('scrub-preview-time'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
RemoteImage(
|
||||||
|
widget.posterUrl,
|
||||||
|
key: const Key('scrub-preview-poster'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallbackColor: AppColors.bar,
|
||||||
|
),
|
||||||
|
if (_frame case final frame?)
|
||||||
|
Image.memory(
|
||||||
|
frame.buffer.asUint8List(
|
||||||
|
frame.offsetInBytes,
|
||||||
|
frame.lengthInBytes,
|
||||||
|
),
|
||||||
|
key: const Key('scrub-preview-frame'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: 240,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _format(Duration value) {
|
||||||
|
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return '$minutes:$seconds';
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import 'loading_progress_track.dart';
|
import 'loading_progress_track.dart';
|
||||||
|
import 'scrub_preview_card.dart';
|
||||||
|
|
||||||
const Key _inactiveTrackKey = Key('progress-inactive');
|
const Key _inactiveTrackKey = Key('progress-inactive');
|
||||||
|
|
||||||
@@ -32,6 +34,9 @@ class SeekProgressBar extends StatefulWidget {
|
|||||||
this.compact = false,
|
this.compact = false,
|
||||||
this.inactiveTrackColor = AppColors.border,
|
this.inactiveTrackColor = AppColors.border,
|
||||||
this.locked = false,
|
this.locked = false,
|
||||||
|
this.thumbnailService,
|
||||||
|
this.episodeId,
|
||||||
|
this.posterUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||||
@@ -42,6 +47,9 @@ class SeekProgressBar extends StatefulWidget {
|
|||||||
final bool compact;
|
final bool compact;
|
||||||
final Color inactiveTrackColor;
|
final Color inactiveTrackColor;
|
||||||
final bool locked;
|
final bool locked;
|
||||||
|
final PlaybackThumbnailService? thumbnailService;
|
||||||
|
final String? episodeId;
|
||||||
|
final String posterUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||||
@@ -69,6 +77,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
_bufferTimer?.cancel();
|
_bufferTimer?.cancel();
|
||||||
_bufferTimer = null;
|
_bufferTimer = null;
|
||||||
_buffering = false;
|
_buffering = false;
|
||||||
|
widget.thumbnailService?.cancelPending();
|
||||||
_attachSource();
|
_attachSource();
|
||||||
}
|
}
|
||||||
if (oldWidget.locked != widget.locked) {
|
if (oldWidget.locked != widget.locked) {
|
||||||
@@ -76,6 +85,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
_bufferTimer = null;
|
_bufferTimer = null;
|
||||||
_buffering = false;
|
_buffering = false;
|
||||||
if (widget.locked) {
|
if (widget.locked) {
|
||||||
|
widget.thumbnailService?.cancelPending();
|
||||||
_previewFraction.value = null;
|
_previewFraction.value = null;
|
||||||
} else {
|
} else {
|
||||||
_syncBufferState(notify: false);
|
_syncBufferState(notify: false);
|
||||||
@@ -87,6 +97,9 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_detachSource(widget.valueListenable);
|
_detachSource(widget.valueListenable);
|
||||||
_bufferTimer?.cancel();
|
_bufferTimer?.cancel();
|
||||||
|
if (_previewFraction.value != null) {
|
||||||
|
widget.thumbnailService?.cancelPending();
|
||||||
|
}
|
||||||
_previewFraction.dispose();
|
_previewFraction.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -148,15 +161,10 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
|
|
||||||
Future<void> _finish(double fraction, Duration duration) async {
|
Future<void> _finish(double fraction, Duration duration) async {
|
||||||
if (widget.locked) return;
|
if (widget.locked) return;
|
||||||
final source = widget.valueListenable;
|
|
||||||
_previewFraction.value = fraction;
|
_previewFraction.value = fraction;
|
||||||
try {
|
widget.thumbnailService?.cancelPending();
|
||||||
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
_previewFraction.value = null;
|
||||||
} finally {
|
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
||||||
if (mounted && widget.valueListenable == source) {
|
|
||||||
_previewFraction.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -194,6 +202,11 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
final previewPosition = _previewFraction.value == null
|
final previewPosition = _previewFraction.value == null
|
||||||
? video.position
|
? video.position
|
||||||
: seekPositionForFraction(fraction, video.duration);
|
: seekPositionForFraction(fraction, video.duration);
|
||||||
|
final showPreview =
|
||||||
|
scrubbing &&
|
||||||
|
widget.showTimes &&
|
||||||
|
widget.thumbnailService != null &&
|
||||||
|
(widget.episodeId?.isNotEmpty ?? false);
|
||||||
final trackHeight = widget.compact
|
final trackHeight = widget.compact
|
||||||
? (scrubbing ? 3.0 : 1.5)
|
? (scrubbing ? 3.0 : 1.5)
|
||||||
: (scrubbing ? 4.0 : 3.0);
|
: (scrubbing ? 4.0 : 3.0);
|
||||||
@@ -203,72 +216,98 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
? (scrubbing ? 32.0 : 20.0)
|
? (scrubbing ? 32.0 : 20.0)
|
||||||
: (scrubbing ? 48.0 : 44.0);
|
: (scrubbing ? 48.0 : 44.0);
|
||||||
|
|
||||||
return Column(
|
return LayoutBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, constraints) {
|
||||||
children: [
|
final track = Column(
|
||||||
if (widget.showTimes && scrubbing)
|
mainAxisSize: MainAxisSize.min,
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
if (widget.showTimes && scrubbing && !showPreview)
|
||||||
child: Text(
|
Padding(
|
||||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
textAlign: TextAlign.center,
|
child: Text(
|
||||||
style: const TextStyle(
|
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||||
color: Colors.white,
|
textAlign: TextAlign.center,
|
||||||
fontSize: 14,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
color: Colors.white,
|
||||||
height: 1.2,
|
fontSize: 14,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
),
|
height: 1.2,
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
height: trackAreaHeight,
|
|
||||||
child: widget.locked || !video.isInitialized
|
|
||||||
? _inactiveTrack(trackHeight)
|
|
||||||
: loading
|
|
||||||
? Center(
|
|
||||||
child: LoadingProgressTrack(
|
|
||||||
height: trackHeight,
|
|
||||||
trackColor: widget.inactiveTrackColor,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SliderTheme(
|
|
||||||
data: SliderTheme.of(context).copyWith(
|
|
||||||
trackHeight: trackHeight,
|
|
||||||
activeTrackColor: AppColors.blue,
|
|
||||||
inactiveTrackColor: widget.inactiveTrackColor,
|
|
||||||
thumbColor: AppColors.onPrimary,
|
|
||||||
thumbShape: RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: widget.compact
|
|
||||||
? (scrubbing ? 6 : 4)
|
|
||||||
: (scrubbing ? 8 : 6),
|
|
||||||
),
|
|
||||||
overlayShape: RoundSliderOverlayShape(
|
|
||||||
overlayRadius: widget.compact ? 12 : 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Semantics(
|
|
||||||
label: widget.semanticsLabel,
|
|
||||||
child: Slider(
|
|
||||||
value: fraction,
|
|
||||||
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
onChangeStart: enabled
|
|
||||||
? (value) {
|
|
||||||
_previewFraction.value = value;
|
|
||||||
widget.onScrubStart?.call();
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onChanged: enabled
|
|
||||||
? (value) => _previewFraction.value = value
|
|
||||||
: null,
|
|
||||||
onChangeEnd: enabled
|
|
||||||
? (value) => unawaited(_finish(value, video.duration))
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
SizedBox(
|
||||||
|
height: trackAreaHeight,
|
||||||
|
child: widget.locked || !video.isInitialized
|
||||||
|
? _inactiveTrack(trackHeight)
|
||||||
|
: loading
|
||||||
|
? Center(
|
||||||
|
child: LoadingProgressTrack(
|
||||||
|
height: trackHeight,
|
||||||
|
trackColor: widget.inactiveTrackColor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: trackHeight,
|
||||||
|
activeTrackColor: AppColors.blue,
|
||||||
|
inactiveTrackColor: widget.inactiveTrackColor,
|
||||||
|
thumbColor: AppColors.onPrimary,
|
||||||
|
thumbShape: RoundSliderThumbShape(
|
||||||
|
enabledThumbRadius: widget.compact
|
||||||
|
? (scrubbing ? 6 : 4)
|
||||||
|
: (scrubbing ? 8 : 6),
|
||||||
|
),
|
||||||
|
overlayShape: RoundSliderOverlayShape(
|
||||||
|
overlayRadius: widget.compact ? 12 : 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Semantics(
|
||||||
|
label: widget.semanticsLabel,
|
||||||
|
child: Slider(
|
||||||
|
value: fraction,
|
||||||
|
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onChangeStart: enabled
|
||||||
|
? (value) {
|
||||||
|
_previewFraction.value = value;
|
||||||
|
widget.onScrubStart?.call();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onChanged: enabled
|
||||||
|
? (value) => _previewFraction.value = value
|
||||||
|
: null,
|
||||||
|
onChangeEnd: enabled
|
||||||
|
? (value) =>
|
||||||
|
unawaited(_finish(value, video.duration))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
track,
|
||||||
|
if (showPreview)
|
||||||
|
Positioned(
|
||||||
|
left: scrubPreviewLeft(
|
||||||
|
trackWidth: constraints.maxWidth,
|
||||||
|
fraction: fraction,
|
||||||
|
),
|
||||||
|
bottom: trackAreaHeight + 8,
|
||||||
|
child: ScrubPreviewCard(
|
||||||
|
service: widget.thumbnailService!,
|
||||||
|
episodeId: widget.episodeId!,
|
||||||
|
posterUrl: widget.posterUrl,
|
||||||
|
position: previewPosition,
|
||||||
|
duration: video.duration,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -13,6 +14,7 @@ import 'package:fruitshorts/core/data/models/home_feed.dart';
|
|||||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
||||||
@@ -609,6 +611,83 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
|
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_scrub_preview_card_from_home_progress', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final controller = ValueNotifier<VideoPlayerValue>(
|
||||||
|
const VideoPlayerValue(
|
||||||
|
duration: Duration(seconds: 100),
|
||||||
|
position: Duration(seconds: 10),
|
||||||
|
isInitialized: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final service = _nullThumbnailService();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: HomeFeedOverlay(
|
||||||
|
l10n: AppLocalizations.of(context)!,
|
||||||
|
drama: const Drama(
|
||||||
|
id: 'preview-drama',
|
||||||
|
title: 'Preview Drama',
|
||||||
|
synopsis: 'synopsis',
|
||||||
|
posterUrl: '',
|
||||||
|
totalEpisodes: 8,
|
||||||
|
),
|
||||||
|
episode: const Episode(
|
||||||
|
id: 'preview-episode',
|
||||||
|
number: 1,
|
||||||
|
title: 'Episode 1',
|
||||||
|
isFree: true,
|
||||||
|
),
|
||||||
|
controller: controller,
|
||||||
|
locked: false,
|
||||||
|
liked: false,
|
||||||
|
saved: false,
|
||||||
|
visible: true,
|
||||||
|
boosting: false,
|
||||||
|
thumbnailService: service,
|
||||||
|
onTogglePlay: () {},
|
||||||
|
onBoostStart: () {},
|
||||||
|
onBoostEnd: () {},
|
||||||
|
onLike: () {},
|
||||||
|
onSave: () {},
|
||||||
|
onComments: () {},
|
||||||
|
onShare: () {},
|
||||||
|
onDetail: () {},
|
||||||
|
onOpenFullPlayer: () {},
|
||||||
|
onMore: () {},
|
||||||
|
onScrubStart: () {},
|
||||||
|
onSeek: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(60, 0));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
|
||||||
|
expect(find.textContaining('/ 01:40'), findsOneWidget);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('scrub-preview-card')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_seek_from_series_bar_when_overlay_is_cleared', (
|
testWidgets('should_seek_from_series_bar_when_overlay_is_cleared', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -683,3 +762,20 @@ void main() {
|
|||||||
expect(seeked!.inSeconds, greaterThan(0));
|
expect(seeked!.inSeconds, greaterThan(0));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackThumbnailService _nullThumbnailService() {
|
||||||
|
return PlaybackThumbnailService(
|
||||||
|
frameLoader: _NullThumbnailLoader(),
|
||||||
|
sourceResolver: (_) =>
|
||||||
|
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||||
|
prefetchDelay: const Duration(hours: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader {
|
||||||
|
@override
|
||||||
|
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('should_bucket_thumbnail_requests_by_whole_seconds', () {
|
||||||
|
expect(playbackThumbnailBucket(Duration.zero), 0);
|
||||||
|
expect(playbackThumbnailBucket(const Duration(milliseconds: 1999)), 1);
|
||||||
|
expect(playbackThumbnailBucket(const Duration(seconds: 12)), 12);
|
||||||
|
expect(playbackThumbnailBucket(const Duration(milliseconds: -300)), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_return_injected_frame_from_loader', () async {
|
||||||
|
final loader = _RecordingLoader((_) async => ByteData(1));
|
||||||
|
final frame = await loader.load(
|
||||||
|
const PlaybackThumbnailFrameRequest(
|
||||||
|
episodeId: 'episode',
|
||||||
|
position: Duration(seconds: 1),
|
||||||
|
source: PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(frame, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_evict_least_recently_used_frame_after_24_entries', () async {
|
||||||
|
final loader = _RecordingLoader((_) async => ByteData(1));
|
||||||
|
final service = _service(loader);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
for (var second = 0; second < 25; second++) {
|
||||||
|
final frame = await service.thumbnail(
|
||||||
|
'episode',
|
||||||
|
Duration(seconds: second),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
frame,
|
||||||
|
isNotNull,
|
||||||
|
reason: 'bucket $second loader=${loader.buckets}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(service.cacheSize, 24);
|
||||||
|
expect(loader.buckets.first, 0);
|
||||||
|
|
||||||
|
await service.thumbnail('episode', const Duration(seconds: 1));
|
||||||
|
expect(loader.buckets.where((bucket) => bucket == 1), hasLength(1));
|
||||||
|
|
||||||
|
await service.thumbnail('episode', Duration.zero);
|
||||||
|
expect(loader.buckets.where((bucket) => bucket == 0), hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_coalesce_same_bucket_while_frame_is_in_flight', () async {
|
||||||
|
final frame = Completer<ByteData?>();
|
||||||
|
final loader = _RecordingLoader((_) => frame.future);
|
||||||
|
final service = _service(loader);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
final first = service.thumbnail('episode', const Duration(seconds: 8));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final second = service.thumbnail(
|
||||||
|
'episode',
|
||||||
|
const Duration(milliseconds: 8999),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(identical(first, second), isTrue);
|
||||||
|
expect(loader.buckets, [8]);
|
||||||
|
frame.complete(ByteData(2));
|
||||||
|
expect(await first, isNotNull);
|
||||||
|
expect(await second, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_drop_superseded_request_and_start_latest_bucket', () async {
|
||||||
|
final frames = <int, Completer<ByteData?>>{};
|
||||||
|
final loader = _RecordingLoader((request) {
|
||||||
|
final completer = Completer<ByteData?>();
|
||||||
|
frames[request.position.inSeconds] = completer;
|
||||||
|
return completer.future;
|
||||||
|
});
|
||||||
|
final service = _service(loader);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
final stale = service.thumbnail('episode', const Duration(seconds: 4));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final latest = service.thumbnail('episode', const Duration(seconds: 9));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(loader.buckets, [4]);
|
||||||
|
frames[4]!.complete(ByteData(4));
|
||||||
|
expect(await stale, isNull);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(loader.buckets, [4, 9]);
|
||||||
|
|
||||||
|
frames[9]!.complete(ByteData(9));
|
||||||
|
expect(await latest, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_cancel_unstarted_request_when_scrub_ends', () async {
|
||||||
|
final firstFrame = Completer<ByteData?>();
|
||||||
|
final loader = _RecordingLoader((request) {
|
||||||
|
if (request.position.inSeconds == 3) return firstFrame.future;
|
||||||
|
return Future<ByteData?>.value(ByteData(7));
|
||||||
|
});
|
||||||
|
final service = _service(loader);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
final active = service.thumbnail('episode', const Duration(seconds: 3));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final pending = service.thumbnail('episode', const Duration(seconds: 7));
|
||||||
|
service.cancelPending();
|
||||||
|
|
||||||
|
expect(await pending, isNull);
|
||||||
|
firstFrame.complete(ByteData(3));
|
||||||
|
expect(await active, isNull);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(loader.buckets, [3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_prefetch_one_neighbor_after_scrub_pause', () async {
|
||||||
|
final loader = _RecordingLoader((_) async => ByteData(1));
|
||||||
|
final service = _service(
|
||||||
|
loader,
|
||||||
|
prefetchDelay: const Duration(milliseconds: 10),
|
||||||
|
);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
await service.thumbnail('episode', const Duration(seconds: 5));
|
||||||
|
expect(loader.buckets, [5]);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||||
|
expect(loader.buckets, [5]);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 12));
|
||||||
|
expect(loader.buckets, [5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_not_prefetch_over_newer_unstarted_bucket', () async {
|
||||||
|
final frames = <int, Completer<ByteData?>>{};
|
||||||
|
final loader = _RecordingLoader((request) {
|
||||||
|
final completer = Completer<ByteData?>();
|
||||||
|
frames[request.position.inSeconds] = completer;
|
||||||
|
return completer.future;
|
||||||
|
});
|
||||||
|
final service = _service(
|
||||||
|
loader,
|
||||||
|
prefetchDelay: const Duration(milliseconds: 10),
|
||||||
|
);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
final first = service.thumbnail('episode', const Duration(seconds: 1));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final latest = service.thumbnail('episode', const Duration(seconds: 3));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
|
expect(loader.buckets, [1]);
|
||||||
|
frames[1]!.complete(ByteData(1));
|
||||||
|
expect(await first, isNull);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(loader.buckets, [1, 3]);
|
||||||
|
|
||||||
|
frames[3]!.complete(ByteData(3));
|
||||||
|
expect(await latest, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_return_null_once_then_negative_cache_failed_bucket', () async {
|
||||||
|
final loader = _RecordingLoader((_) => Completer<ByteData?>().future);
|
||||||
|
final service = _service(
|
||||||
|
loader,
|
||||||
|
requestTimeout: const Duration(milliseconds: 10),
|
||||||
|
);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service.thumbnail('episode', const Duration(seconds: 2)),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await service.thumbnail('episode', const Duration(seconds: 2)),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(loader.buckets, [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_convert_loader_exception_to_null', () async {
|
||||||
|
final loader = _RecordingLoader((_) async => throw StateError('decode'));
|
||||||
|
final service = _service(loader);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service.thumbnail('episode', const Duration(seconds: 6)),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackThumbnailService _service(
|
||||||
|
PlaybackThumbnailFrameLoader loader, {
|
||||||
|
Duration requestTimeout = const Duration(hours: 1),
|
||||||
|
Duration prefetchDelay = const Duration(hours: 1),
|
||||||
|
}) {
|
||||||
|
return PlaybackThumbnailService(
|
||||||
|
frameLoader: loader,
|
||||||
|
sourceResolver: (_) =>
|
||||||
|
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||||
|
requestTimeout: requestTimeout,
|
||||||
|
prefetchDelay: prefetchDelay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordingLoader implements PlaybackThumbnailFrameLoader {
|
||||||
|
_RecordingLoader(this._handler);
|
||||||
|
|
||||||
|
final Future<ByteData?> Function(PlaybackThumbnailFrameRequest request)
|
||||||
|
_handler;
|
||||||
|
final List<int> buckets = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) {
|
||||||
|
buckets.add(request.position.inSeconds);
|
||||||
|
return _handler(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
@@ -17,6 +21,18 @@ void main() {
|
|||||||
expect(seekPositionForFraction(1, duration), lessThan(duration));
|
expect(seekPositionForFraction(1, duration), lessThan(duration));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should_clamp_preview_card_inside_track_safe_margins', () {
|
||||||
|
expect(
|
||||||
|
scrubPreviewLeft(trackWidth: 300, fraction: 0),
|
||||||
|
scrubPreviewSafeMargin,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
scrubPreviewLeft(trackWidth: 300, fraction: 1),
|
||||||
|
300 - scrubPreviewWidth - scrubPreviewSafeMargin,
|
||||||
|
);
|
||||||
|
expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_show_inactive_track_when_video_is_not_initialized', (
|
testWidgets('should_show_inactive_track_when_video_is_not_initialized', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -233,6 +249,59 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('should_show_frame_card_with_cover_then_hide_on_release', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final video = ValueNotifier<VideoPlayerValue>(
|
||||||
|
const VideoPlayerValue(
|
||||||
|
duration: Duration(seconds: 12),
|
||||||
|
position: Duration(seconds: 3),
|
||||||
|
isInitialized: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final service = _thumbnailService();
|
||||||
|
addTearDown(video.dispose);
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 300,
|
||||||
|
child: SeekProgressBar(
|
||||||
|
valueListenable: video,
|
||||||
|
semanticsLabel: 'Seek video',
|
||||||
|
episodeId: 'episode-1',
|
||||||
|
posterUrl: '',
|
||||||
|
thumbnailService: service,
|
||||||
|
onSeek: (_) async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byType(Slider)),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(80, 0));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('scrub-preview-frame')), findsNothing);
|
||||||
|
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('scrub-preview-card'))).width,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('scrub-preview-card')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
|
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -267,3 +336,20 @@ void main() {
|
|||||||
expect(find.textContaining('00:12'), findsNothing);
|
expect(find.textContaining('00:12'), findsNothing);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackThumbnailService _thumbnailService() {
|
||||||
|
return PlaybackThumbnailService(
|
||||||
|
frameLoader: _NullThumbnailLoader(),
|
||||||
|
sourceResolver: (_) =>
|
||||||
|
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||||
|
prefetchDelay: const Duration(hours: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader {
|
||||||
|
@override
|
||||||
|
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user