feat(player): 增加进度条拖动画面预览
This commit is contained in:
1 parent
d06dce9753
commit
51c56d884b
15 files changed
+1493
-77
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user