From 51c56d884b39f5f13491a6dfffef05fd17abdb44 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 11:03:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(player):=20=E5=A2=9E=E5=8A=A0=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E6=8B=96=E5=8A=A8=E7=94=BB=E9=9D=A2=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/stormg/lumadrama/MainActivity.kt | 116 ++++++ docs/plans/2026-10-22-scrub-frame-preview.md | 62 +++ docs/plans/PROGRESS.md | 53 +++ ios/Runner/AppDelegate.swift | 85 ++++ .../services/playback_thumbnail_service.dart | 365 ++++++++++++++++++ lib/pages/player/player_view.dart | 60 +++ .../player/widgets/home_feed_overlay.dart | 15 +- .../widgets/home_feed_overlay_parts.dart | 49 ++- lib/pages/player/widgets/player_overlay.dart | 4 + .../player/widgets/player_overlay_models.dart | 5 + .../player/widgets/scrub_preview_card.dart | 164 ++++++++ .../player/widgets/seek_progress_bar.dart | 183 +++++---- test/home/home_preview_layout_test.dart | 96 +++++ .../playback_thumbnail_service_test.dart | 227 +++++++++++ test/playback/seek_progress_bar_test.dart | 86 +++++ 15 files changed, 1493 insertions(+), 77 deletions(-) create mode 100644 docs/plans/2026-10-22-scrub-frame-preview.md create mode 100644 lib/core/data/services/playback_thumbnail_service.dart create mode 100644 lib/pages/player/widgets/scrub_preview_card.dart create mode 100644 test/playback/playback_thumbnail_service_test.dart diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt index a558249..7db28d2 100644 --- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -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("timeMs"))?.toLong() + if (timeMs == null) { + result.success(null) + return@setMethodCallHandler + } + val localPath = call.argument("localPath").orEmpty() + val remoteUrl = call.argument("url").orEmpty() + val rawHeaders = call.argument>("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 + ): 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 + } } diff --git a/docs/plans/2026-10-22-scrub-frame-preview.md b/docs/plans/2026-10-22-scrub-frame-preview.md new file mode 100644 index 0000000..4c4c5bf --- /dev/null +++ b/docs/plans/2026-10-22-scrub-frame-preview.md @@ -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 真机验收。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 2674012..7b0e427 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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 真机验收。 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index f94360c..5329c45 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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) + } + } } } diff --git a/lib/core/data/services/playback_thumbnail_service.dart b/lib/core/data/services/playback_thumbnail_service.dart new file mode 100644 index 0000000..c817858 --- /dev/null +++ b/lib/core/data/services/playback_thumbnail_service.dart @@ -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 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 toChannelArguments() => { + 'episodeId': episodeId, + 'timeMs': position.inMilliseconds, + 'localPath': source.localPath, + 'url': source.remoteUrl, + 'headers': source.headers, + }; +} + +/// 可注入的原生取帧接口。 +abstract interface class PlaybackThumbnailFrameLoader { + Future 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 load(PlaybackThumbnailFrameRequest request) async { + final bytes = await _channel.invokeMethod( + '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 _cache = LinkedHashMap(); + final LinkedHashMap _unavailable = LinkedHashMap(); + + _ThumbnailRequest? _inFlight; + Future? _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 thumbnail(String episodeId, Duration position) { + if (_disposed) return Future.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.value(cached); + } + if (_inFlight?.key == key && _inFlightFuture != null) { + _schedulePrefetch(key); + return _inFlightFuture!; + } + if (_unavailable.containsKey(key)) { + _promoteUnavailable(key); + _schedulePrefetch(key); + return Future.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 _start(_ThumbnailRequest request) { + final completer = Completer(); + _inFlight = request; + _inFlightFuture = completer.future; + unawaited(_run(request, completer)); + return completer.future; + } + + Future _queue(_ThumbnailRequest request) { + final completer = Completer(); + _queued = request.withCompleter(completer); + return completer.future; + } + + Future _run( + _ThumbnailRequest request, + Completer 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 _loadFrame(_ThumbnailRequest request) { + PlaybackThumbnailSource? source; + try { + source = sourceResolver?.call(request.episodeId); + } catch (_) { + return Future.value(); + } + if (source == null || !source.isAvailable) { + return Future.value(); + } + final frame = PlaybackThumbnailFrameRequest( + episodeId: request.episodeId, + position: Duration(seconds: request.bucket), + source: source, + ); + final result = Completer(); + 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? completer, + }) : completer = completer ?? Completer(); + + final String key; + final String episodeId; + final int bucket; + final Completer completer; + + _ThumbnailRequest withCompleter(Completer value) => + _ThumbnailRequest( + key: key, + episodeId: episodeId, + bucket: bucket, + completer: value, + ); +} diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 20b0e49..1f6d8e7 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -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 with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; + late final PlaybackThumbnailService _thumbnailService; PageRoute? _subscribedRoute; bool _chromeVisible = true; double _playbackSpeed = 1.0; @@ -125,6 +128,9 @@ class _PlayerPageState extends State downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); + _thumbnailService = PlaybackThumbnailService( + sourceResolver: _resolveThumbnailSource, + ); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -161,6 +167,57 @@ class _PlayerPageState extends State String _feedSignature(List? 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 void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); + _thumbnailService.dispose(); logic.dispose(); super.dispose(); } @@ -547,6 +605,7 @@ class _PlayerPageState extends State onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, + thumbnailService: _thumbnailService, ) else PlayerPlaybackOverlay( @@ -569,6 +628,7 @@ class _PlayerPageState extends State playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, boosting: _boosting, + thumbnailService: _thumbnailService, ), actions: PlayerOverlayActions( onBack: widget.embedded diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e6c99db..e36a961 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -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? onSeek; + final PlaybackThumbnailService? thumbnailService; @override State createState() => _HomeFeedOverlayState(); @@ -287,7 +292,11 @@ class _HomeFeedOverlayState extends State { ), ), ), - 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 { child: SizedBox( height: _fullBarHeight, child: Stack( + clipBehavior: Clip.none, children: [ Positioned.fill( child: Container( @@ -394,6 +404,9 @@ class _HomeFeedOverlayState extends State { hitHeight: _progressHitHeight, onScrubStart: widget.onScrubStart, onSeek: widget.onSeek, + thumbnailService: widget.thumbnailService, + episodeId: widget.episode.id, + posterUrl: widget.drama.posterUrl, ), ), ], diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index ebf4578..cd08b58 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -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? controller; @@ -25,6 +28,9 @@ class _FeedProgressBar extends StatefulWidget { final double hitHeight; final VoidCallback? onScrubStart; final ValueChanged? 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); } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 5d3fdcb..79b7a1b 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -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, diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 1a2dd92..75d19df 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -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; } diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart new file mode 100644 index 0000000..e38f8f1 --- /dev/null +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -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 createState() => _ScrubPreviewCardState(); +} + +class _ScrubPreviewCardState extends State { + 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'; +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index bc58c53..aa96945 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -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? 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 createState() => _SeekProgressBarState(); @@ -69,6 +77,7 @@ class _SeekProgressBarState extends State { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + widget.thumbnailService?.cancelPending(); _attachSource(); } if (oldWidget.locked != widget.locked) { @@ -76,6 +85,7 @@ class _SeekProgressBarState extends State { _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 { 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 { Future _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 { 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 { ? (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, + ), + ), + ], + ); + }, ); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 275759f..7a50e5a 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -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( + 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 load(PlaybackThumbnailFrameRequest request) async => null; + + @override + void dispose() {} +} diff --git a/test/playback/playback_thumbnail_service_test.dart b/test/playback/playback_thumbnail_service_test.dart new file mode 100644 index 0000000..c04fd86 --- /dev/null +++ b/test/playback/playback_thumbnail_service_test.dart @@ -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(); + final loader = _RecordingLoader((_) => frame.future); + final service = _service(loader); + addTearDown(service.dispose); + + final first = service.thumbnail('episode', const Duration(seconds: 8)); + await Future.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 = >{}; + final loader = _RecordingLoader((request) { + final completer = Completer(); + 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.delayed(Duration.zero); + final latest = service.thumbnail('episode', const Duration(seconds: 9)); + await Future.delayed(Duration.zero); + + expect(loader.buckets, [4]); + frames[4]!.complete(ByteData(4)); + expect(await stale, isNull); + await Future.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(); + final loader = _RecordingLoader((request) { + if (request.position.inSeconds == 3) return firstFrame.future; + return Future.value(ByteData(7)); + }); + final service = _service(loader); + addTearDown(service.dispose); + + final active = service.thumbnail('episode', const Duration(seconds: 3)); + await Future.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.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.delayed(const Duration(milliseconds: 5)); + expect(loader.buckets, [5]); + + await Future.delayed(const Duration(milliseconds: 12)); + expect(loader.buckets, [5, 6]); + }); + + test('should_not_prefetch_over_newer_unstarted_bucket', () async { + final frames = >{}; + final loader = _RecordingLoader((request) { + final completer = Completer(); + 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.delayed(Duration.zero); + final latest = service.thumbnail('episode', const Duration(seconds: 3)); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(loader.buckets, [1]); + frames[1]!.complete(ByteData(1)); + expect(await first, isNull); + await Future.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().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 Function(PlaybackThumbnailFrameRequest request) + _handler; + final List buckets = []; + + @override + Future load(PlaybackThumbnailFrameRequest request) { + buckets.add(request.position.inSeconds); + return _handler(request); + } + + @override + void dispose() {} +} diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 04f0fae..b7713c9 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -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( + 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 load(PlaybackThumbnailFrameRequest request) async => null; + + @override + void dispose() {} +}