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.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 真机验收。
+53
View File
@@ -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 真机验收。
+85
View File
@@ -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,
);
}
+60
View File
@@ -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';
}
+111 -72
View File
@@ -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,
),
),
],
);
},
);
}
+96
View File
@@ -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() {}
}
+86
View File
@@ -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() {}
}