merge: 合并进度条画面预览

This commit is contained in:
Zhou Weidong committed 2026-10-22 11:03:25 +08:00
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 真机验收。
+53
View File
@@ -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 真机验收。
+85
View File
@@ -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,
);
}
+60
View File
@@ -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';
}
+111 -72
View File
@@ -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,
),
),
],
);
},
); );
} }
+96
View File
@@ -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() {}
}
+86
View File
@@ -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() {}
}