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.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
|
||||
@@ -15,12 +20,19 @@ 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?) {
|
||||
@@ -86,6 +98,47 @@ class MainActivity : FlutterActivity() {
|
||||
emitImeInset(inset)
|
||||
result.success(inset)
|
||||
}
|
||||
thumbnailChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"com.stormg.lumadrama/thumbnail"
|
||||
)
|
||||
thumbnailChannel?.setMethodCallHandler { call, result ->
|
||||
if (call.method != "frame") {
|
||||
result.notImplemented()
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
val timeMs = (call.argument<Number>("timeMs"))?.toLong()
|
||||
if (timeMs == null) {
|
||||
result.success(null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
val localPath = call.argument<String>("localPath").orEmpty()
|
||||
val remoteUrl = call.argument<String>("url").orEmpty()
|
||||
val rawHeaders = call.argument<Map<*, *>>("headers")
|
||||
val headers = rawHeaders?.entries
|
||||
?.associate { it.key.toString() to it.value.toString() }
|
||||
.orEmpty()
|
||||
val responded = AtomicBoolean(false)
|
||||
val task = thumbnailExecutor.submit {
|
||||
val bytes = renderThumbnail(
|
||||
timeMs = timeMs,
|
||||
localPath = localPath,
|
||||
remoteUrl = remoteUrl,
|
||||
headers = headers
|
||||
)
|
||||
thumbnailHandler.post {
|
||||
if (responded.compareAndSet(false, true)) {
|
||||
result.success(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
thumbnailHandler.postDelayed({
|
||||
if (responded.compareAndSet(false, true)) {
|
||||
task.cancel(true)
|
||||
result.success(null)
|
||||
}
|
||||
}, thumbnailTimeoutMs)
|
||||
}
|
||||
val decorView = window.decorView
|
||||
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
|
||||
emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
|
||||
@@ -106,6 +159,59 @@ class MainActivity : FlutterActivity() {
|
||||
keyboardChannel?.invokeMethod("insetsChanged", value)
|
||||
}
|
||||
|
||||
private fun renderThumbnail(
|
||||
timeMs: Long,
|
||||
localPath: String,
|
||||
remoteUrl: String,
|
||||
headers: Map<String, String>
|
||||
): ByteArray? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
var frame: Bitmap? = null
|
||||
var scaled: Bitmap? = null
|
||||
return try {
|
||||
when {
|
||||
localPath.isNotEmpty() -> retriever.setDataSource(normalizeLocalPath(localPath))
|
||||
remoteUrl.isNotEmpty() -> retriever.setDataSource(remoteUrl, headers)
|
||||
else -> return null
|
||||
}
|
||||
frame = retriever.getFrameAtTime(
|
||||
timeMs * 1000,
|
||||
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
|
||||
)
|
||||
val source = frame ?: return null
|
||||
scaled = if (source.width > thumbnailMaxWidth) {
|
||||
val targetHeight = (source.height * thumbnailMaxWidth / source.width)
|
||||
.coerceAtLeast(1)
|
||||
Bitmap.createScaledBitmap(
|
||||
source,
|
||||
thumbnailMaxWidth,
|
||||
targetHeight,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
source
|
||||
}
|
||||
val outputBitmap = scaled ?: source
|
||||
val output = ByteArrayOutputStream()
|
||||
val compressed = outputBitmap.compress(
|
||||
Bitmap.CompressFormat.JPEG,
|
||||
thumbnailJpegQuality,
|
||||
output
|
||||
)
|
||||
if (!compressed) return null
|
||||
output.toByteArray()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
if (scaled != null && scaled !== frame) scaled.recycle()
|
||||
frame?.recycle()
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeLocalPath(path: String): String =
|
||||
if (path.startsWith("file:")) Uri.parse(path).path ?: path else path
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode)
|
||||
@@ -124,6 +230,16 @@ class MainActivity : FlutterActivity() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
|
||||
keyboardChannel?.setMethodCallHandler(null)
|
||||
keyboardChannel = null
|
||||
thumbnailChannel?.setMethodCallHandler(null)
|
||||
thumbnailChannel = null
|
||||
thumbnailHandler.removeCallbacksAndMessages(null)
|
||||
thumbnailExecutor.shutdownNow()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val thumbnailMaxWidth = 240
|
||||
private const val thumbnailJpegQuality = 70
|
||||
private const val thumbnailTimeoutMs = 1500L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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。全量静态分析零告警,
|
||||
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 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,6 +35,11 @@ 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]?
|
||||
@@ -44,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,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:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/comment_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
@@ -13,6 +14,7 @@ import '../../core/data/services/download_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/data/services/playback_position_cache.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
import '../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../core/data/services/video_player_port.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
@@ -103,6 +105,7 @@ class PlayerPage extends StatefulWidget {
|
||||
class _PlayerPageState extends State<PlayerPage>
|
||||
with WidgetsBindingObserver, RouteAware {
|
||||
late final PlayerLogic logic;
|
||||
late final PlaybackThumbnailService _thumbnailService;
|
||||
PageRoute<dynamic>? _subscribedRoute;
|
||||
bool _chromeVisible = true;
|
||||
double _playbackSpeed = 1.0;
|
||||
@@ -125,6 +128,9 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
downloads: widget.downloads,
|
||||
);
|
||||
logic.attach(widget, isMounted: () => mounted);
|
||||
_thumbnailService = PlaybackThumbnailService(
|
||||
sourceResolver: _resolveThumbnailSource,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
logic.start(
|
||||
factory: context.read<PlayerFactory>(),
|
||||
@@ -161,6 +167,57 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
String _feedSignature(List<Drama>? dramas) =>
|
||||
dramas == null ? '' : dramas.map((drama) => drama.id).join('|');
|
||||
|
||||
PlaybackThumbnailSource? _resolveThumbnailSource(String episodeId) {
|
||||
final localPath = widget.downloads?.localPathFor(episodeId);
|
||||
Episode? episode;
|
||||
for (final item in logic.sessionEpisodes) {
|
||||
if (item.id == episodeId) {
|
||||
episode = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (localPath != null && localPath.isNotEmpty) {
|
||||
return PlaybackThumbnailSource(localPath: localPath);
|
||||
}
|
||||
final rawUrl = episode?.videoUrl ?? '';
|
||||
final active = logic.session.active;
|
||||
final activeSource = active is VideoPlayerPort
|
||||
? active.controller.dataSource
|
||||
: '';
|
||||
final currentEpisodeId = logic.sessionEpisodes.isEmpty
|
||||
? null
|
||||
: logic.session.currentEpisode.id;
|
||||
final activeMatches =
|
||||
currentEpisodeId == episodeId && activeSource.isNotEmpty;
|
||||
if (activeMatches && activeSource.startsWith('file:')) {
|
||||
try {
|
||||
return PlaybackThumbnailSource(
|
||||
localPath: Uri.parse(activeSource).toFilePath(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (rawUrl.startsWith('file:')) {
|
||||
try {
|
||||
return PlaybackThumbnailSource(
|
||||
localPath: Uri.parse(rawUrl).toFilePath(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
final remoteUrl = AppConfig.mediaUrl(
|
||||
rawUrl.isNotEmpty
|
||||
? rawUrl
|
||||
: activeMatches
|
||||
? activeSource
|
||||
: '',
|
||||
);
|
||||
if (remoteUrl.isEmpty) return null;
|
||||
return PlaybackThumbnailSource(remoteUrl: remoteUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -190,6 +247,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
appRouteObserver.unsubscribe(this);
|
||||
_thumbnailService.dispose();
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -547,6 +605,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
thumbnailService: _thumbnailService,
|
||||
)
|
||||
else
|
||||
PlayerPlaybackOverlay(
|
||||
@@ -569,6 +628,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
playbackSpeed: _playbackSpeed,
|
||||
totalEpisodes: logic.visibleIndices.length,
|
||||
boosting: _boosting,
|
||||
thumbnailService: _thumbnailService,
|
||||
),
|
||||
actions: PlayerOverlayActions(
|
||||
onBack: widget.embedded
|
||||
|
||||
@@ -7,12 +7,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'interaction_rail.dart';
|
||||
import 'loading_progress_track.dart';
|
||||
import 'scrub_preview_card.dart';
|
||||
import 'seek_progress_bar.dart';
|
||||
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
@@ -59,6 +62,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
this.authorNote = '',
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.thumbnailService,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
@@ -86,6 +90,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
final VoidCallback onMore;
|
||||
final VoidCallback? onScrubStart;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
|
||||
@override
|
||||
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(
|
||||
height: _fullBarHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
@@ -394,6 +404,9 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
hitHeight: _progressHitHeight,
|
||||
onScrubStart: widget.onScrubStart,
|
||||
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,
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.thumbnailService,
|
||||
required this.episodeId,
|
||||
required this.posterUrl,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? controller;
|
||||
@@ -25,6 +28,9 @@ class _FeedProgressBar extends StatefulWidget {
|
||||
final double hitHeight;
|
||||
final VoidCallback? onScrubStart;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
final String episodeId;
|
||||
final String posterUrl;
|
||||
|
||||
@override
|
||||
State<_FeedProgressBar> createState() => _FeedProgressBarState();
|
||||
@@ -51,6 +57,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
@@ -58,6 +65,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
if (widget.locked) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_preview = null;
|
||||
} else {
|
||||
_syncBufferState(notify: false);
|
||||
@@ -69,6 +77,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
void dispose() {
|
||||
_detachSource(widget.controller);
|
||||
_bufferTimer?.cancel();
|
||||
if (_preview != null) widget.thumbnailService?.cancelPending();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -104,6 +113,14 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
!widget.locked &&
|
||||
!loading &&
|
||||
(source?.value.isInitialized ?? false);
|
||||
final video = source?.value;
|
||||
final previewPosition = _preview == null || video == null
|
||||
? null
|
||||
: seekPositionForFraction(_preview!, video.duration);
|
||||
final showPreview =
|
||||
previewPosition != null &&
|
||||
widget.thumbnailService != null &&
|
||||
widget.episodeId.isNotEmpty;
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-progress'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -117,10 +134,28 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
? (details) => _update(details.localPosition.dx / width)
|
||||
: null,
|
||||
onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null,
|
||||
onHorizontalDragCancel: interactive
|
||||
? () => setState(() => _preview = null)
|
||||
: null,
|
||||
child: track,
|
||||
onHorizontalDragCancel: interactive ? _cancelPreview : null,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
track,
|
||||
if (showPreview)
|
||||
Positioned(
|
||||
left: scrubPreviewLeft(
|
||||
trackWidth: width,
|
||||
fraction: _preview!,
|
||||
),
|
||||
bottom: widget.hitHeight + 8,
|
||||
child: ScrubPreviewCard(
|
||||
service: widget.thumbnailService!,
|
||||
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()),
|
||||
);
|
||||
}
|
||||
widget.thumbnailService?.cancelPending();
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
void _cancelPreview() {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
@@ -264,6 +265,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
showTimes: true,
|
||||
compact: true,
|
||||
locked: data.locked,
|
||||
thumbnailService: data.thumbnailService,
|
||||
episodeId: data.episode.id,
|
||||
posterUrl: data.drama.posterUrl,
|
||||
inactiveTrackColor: Colors.white24,
|
||||
onScrubStart: actions.onScrubStart,
|
||||
onSeek: actions.onSeek,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 播放浮层展示数据。
|
||||
@@ -24,6 +25,7 @@ class PlayerOverlayData {
|
||||
required this.totalEpisodes,
|
||||
required this.boosting,
|
||||
required this.locked,
|
||||
this.thumbnailService,
|
||||
this.authorNote = '',
|
||||
});
|
||||
|
||||
@@ -69,6 +71,9 @@ class PlayerOverlayData {
|
||||
/// 当前剧集是否锁定。
|
||||
final bool locked;
|
||||
|
||||
/// 拖动进度时按当前集和时间点取帧。
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
|
||||
/// 作者声明;没有后端字段时为空。
|
||||
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:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'loading_progress_track.dart';
|
||||
import 'scrub_preview_card.dart';
|
||||
|
||||
const Key _inactiveTrackKey = Key('progress-inactive');
|
||||
|
||||
@@ -32,6 +34,9 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.compact = false,
|
||||
this.inactiveTrackColor = AppColors.border,
|
||||
this.locked = false,
|
||||
this.thumbnailService,
|
||||
this.episodeId,
|
||||
this.posterUrl = '',
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -42,6 +47,9 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final bool compact;
|
||||
final Color inactiveTrackColor;
|
||||
final bool locked;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
final String? episodeId;
|
||||
final String posterUrl;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -69,6 +77,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
@@ -76,6 +85,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
if (widget.locked) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_previewFraction.value = null;
|
||||
} else {
|
||||
_syncBufferState(notify: false);
|
||||
@@ -87,6 +97,9 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
void dispose() {
|
||||
_detachSource(widget.valueListenable);
|
||||
_bufferTimer?.cancel();
|
||||
if (_previewFraction.value != null) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
}
|
||||
_previewFraction.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -148,15 +161,10 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
|
||||
Future<void> _finish(double fraction, Duration duration) async {
|
||||
if (widget.locked) return;
|
||||
final source = widget.valueListenable;
|
||||
_previewFraction.value = fraction;
|
||||
try {
|
||||
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
||||
} finally {
|
||||
if (mounted && widget.valueListenable == source) {
|
||||
_previewFraction.value = null;
|
||||
}
|
||||
}
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_previewFraction.value = null;
|
||||
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -194,6 +202,11 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
final previewPosition = _previewFraction.value == null
|
||||
? video.position
|
||||
: seekPositionForFraction(fraction, video.duration);
|
||||
final showPreview =
|
||||
scrubbing &&
|
||||
widget.showTimes &&
|
||||
widget.thumbnailService != null &&
|
||||
(widget.episodeId?.isNotEmpty ?? false);
|
||||
final trackHeight = widget.compact
|
||||
? (scrubbing ? 3.0 : 1.5)
|
||||
: (scrubbing ? 4.0 : 3.0);
|
||||
@@ -203,72 +216,98 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
? (scrubbing ? 32.0 : 20.0)
|
||||
: (scrubbing ? 48.0 : 44.0);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.showTimes && scrubbing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: trackAreaHeight,
|
||||
child: 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 LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final track = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.showTimes && scrubbing && !showPreview)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
height: trackAreaHeight,
|
||||
child: widget.locked || !video.isInitialized
|
||||
? _inactiveTrack(trackHeight)
|
||||
: loading
|
||||
? Center(
|
||||
child: LoadingProgressTrack(
|
||||
height: trackHeight,
|
||||
trackColor: widget.inactiveTrackColor,
|
||||
),
|
||||
)
|
||||
: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: trackHeight,
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.inactiveTrackColor,
|
||||
thumbColor: AppColors.onPrimary,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: widget.compact
|
||||
? (scrubbing ? 6 : 4)
|
||||
: (scrubbing ? 8 : 6),
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
child: Slider(
|
||||
value: fraction,
|
||||
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
||||
padding: EdgeInsets.zero,
|
||||
onChangeStart: enabled
|
||||
? (value) {
|
||||
_previewFraction.value = value;
|
||||
widget.onScrubStart?.call();
|
||||
}
|
||||
: null,
|
||||
onChanged: enabled
|
||||
? (value) => _previewFraction.value = value
|
||||
: null,
|
||||
onChangeEnd: enabled
|
||||
? (value) =>
|
||||
unawaited(_finish(value, video.duration))
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
track,
|
||||
if (showPreview)
|
||||
Positioned(
|
||||
left: scrubPreviewLeft(
|
||||
trackWidth: constraints.maxWidth,
|
||||
fraction: fraction,
|
||||
),
|
||||
bottom: trackAreaHeight + 8,
|
||||
child: ScrubPreviewCard(
|
||||
service: widget.thumbnailService!,
|
||||
episodeId: widget.episodeId!,
|
||||
posterUrl: widget.posterUrl,
|
||||
position: previewPosition,
|
||||
duration: video.duration,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.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/playback_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/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
||||
@@ -609,6 +611,83 @@ void main() {
|
||||
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', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -683,3 +762,20 @@ void main() {
|
||||
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
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.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/pages/player/widgets/scrub_preview_card.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
@@ -17,6 +21,18 @@ void main() {
|
||||
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', (
|
||||
tester,
|
||||
) 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', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -267,3 +336,20 @@ void main() {
|
||||
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