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,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/comment_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
@@ -13,6 +14,7 @@ import '../../core/data/services/download_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/data/services/playback_position_cache.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
import '../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../core/data/services/video_player_port.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
@@ -103,6 +105,7 @@ class PlayerPage extends StatefulWidget {
|
||||
class _PlayerPageState extends State<PlayerPage>
|
||||
with WidgetsBindingObserver, RouteAware {
|
||||
late final PlayerLogic logic;
|
||||
late final PlaybackThumbnailService _thumbnailService;
|
||||
PageRoute<dynamic>? _subscribedRoute;
|
||||
bool _chromeVisible = true;
|
||||
double _playbackSpeed = 1.0;
|
||||
@@ -125,6 +128,9 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
downloads: widget.downloads,
|
||||
);
|
||||
logic.attach(widget, isMounted: () => mounted);
|
||||
_thumbnailService = PlaybackThumbnailService(
|
||||
sourceResolver: _resolveThumbnailSource,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
logic.start(
|
||||
factory: context.read<PlayerFactory>(),
|
||||
@@ -161,6 +167,57 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
String _feedSignature(List<Drama>? dramas) =>
|
||||
dramas == null ? '' : dramas.map((drama) => drama.id).join('|');
|
||||
|
||||
PlaybackThumbnailSource? _resolveThumbnailSource(String episodeId) {
|
||||
final localPath = widget.downloads?.localPathFor(episodeId);
|
||||
Episode? episode;
|
||||
for (final item in logic.sessionEpisodes) {
|
||||
if (item.id == episodeId) {
|
||||
episode = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (localPath != null && localPath.isNotEmpty) {
|
||||
return PlaybackThumbnailSource(localPath: localPath);
|
||||
}
|
||||
final rawUrl = episode?.videoUrl ?? '';
|
||||
final active = logic.session.active;
|
||||
final activeSource = active is VideoPlayerPort
|
||||
? active.controller.dataSource
|
||||
: '';
|
||||
final currentEpisodeId = logic.sessionEpisodes.isEmpty
|
||||
? null
|
||||
: logic.session.currentEpisode.id;
|
||||
final activeMatches =
|
||||
currentEpisodeId == episodeId && activeSource.isNotEmpty;
|
||||
if (activeMatches && activeSource.startsWith('file:')) {
|
||||
try {
|
||||
return PlaybackThumbnailSource(
|
||||
localPath: Uri.parse(activeSource).toFilePath(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (rawUrl.startsWith('file:')) {
|
||||
try {
|
||||
return PlaybackThumbnailSource(
|
||||
localPath: Uri.parse(rawUrl).toFilePath(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
final remoteUrl = AppConfig.mediaUrl(
|
||||
rawUrl.isNotEmpty
|
||||
? rawUrl
|
||||
: activeMatches
|
||||
? activeSource
|
||||
: '',
|
||||
);
|
||||
if (remoteUrl.isEmpty) return null;
|
||||
return PlaybackThumbnailSource(remoteUrl: remoteUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -190,6 +247,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
appRouteObserver.unsubscribe(this);
|
||||
_thumbnailService.dispose();
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -547,6 +605,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
thumbnailService: _thumbnailService,
|
||||
)
|
||||
else
|
||||
PlayerPlaybackOverlay(
|
||||
@@ -569,6 +628,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
playbackSpeed: _playbackSpeed,
|
||||
totalEpisodes: logic.visibleIndices.length,
|
||||
boosting: _boosting,
|
||||
thumbnailService: _thumbnailService,
|
||||
),
|
||||
actions: PlayerOverlayActions(
|
||||
onBack: widget.embedded
|
||||
|
||||
@@ -7,12 +7,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'interaction_rail.dart';
|
||||
import 'loading_progress_track.dart';
|
||||
import 'scrub_preview_card.dart';
|
||||
import 'seek_progress_bar.dart';
|
||||
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
@@ -59,6 +62,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
this.authorNote = '',
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.thumbnailService,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
@@ -86,6 +90,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
final VoidCallback onMore;
|
||||
final VoidCallback? onScrubStart;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
|
||||
@override
|
||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||
@@ -287,7 +292,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -342,6 +351,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
child: SizedBox(
|
||||
height: _fullBarHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
@@ -394,6 +404,9 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
hitHeight: _progressHitHeight,
|
||||
onScrubStart: widget.onScrubStart,
|
||||
onSeek: widget.onSeek,
|
||||
thumbnailService: widget.thumbnailService,
|
||||
episodeId: widget.episode.id,
|
||||
posterUrl: widget.drama.posterUrl,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -17,6 +17,9 @@ class _FeedProgressBar extends StatefulWidget {
|
||||
required this.hitHeight,
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.thumbnailService,
|
||||
required this.episodeId,
|
||||
required this.posterUrl,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? controller;
|
||||
@@ -25,6 +28,9 @@ class _FeedProgressBar extends StatefulWidget {
|
||||
final double hitHeight;
|
||||
final VoidCallback? onScrubStart;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
final String episodeId;
|
||||
final String posterUrl;
|
||||
|
||||
@override
|
||||
State<_FeedProgressBar> createState() => _FeedProgressBarState();
|
||||
@@ -51,6 +57,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
@@ -58,6 +65,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
if (widget.locked) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_preview = null;
|
||||
} else {
|
||||
_syncBufferState(notify: false);
|
||||
@@ -69,6 +77,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
void dispose() {
|
||||
_detachSource(widget.controller);
|
||||
_bufferTimer?.cancel();
|
||||
if (_preview != null) widget.thumbnailService?.cancelPending();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -104,6 +113,14 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
!widget.locked &&
|
||||
!loading &&
|
||||
(source?.value.isInitialized ?? false);
|
||||
final video = source?.value;
|
||||
final previewPosition = _preview == null || video == null
|
||||
? null
|
||||
: seekPositionForFraction(_preview!, video.duration);
|
||||
final showPreview =
|
||||
previewPosition != null &&
|
||||
widget.thumbnailService != null &&
|
||||
widget.episodeId.isNotEmpty;
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-progress'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -117,10 +134,28 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
? (details) => _update(details.localPosition.dx / width)
|
||||
: null,
|
||||
onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null,
|
||||
onHorizontalDragCancel: interactive
|
||||
? () => setState(() => _preview = null)
|
||||
: null,
|
||||
child: track,
|
||||
onHorizontalDragCancel: interactive ? _cancelPreview : null,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
track,
|
||||
if (showPreview)
|
||||
Positioned(
|
||||
left: scrubPreviewLeft(
|
||||
trackWidth: width,
|
||||
fraction: _preview!,
|
||||
),
|
||||
bottom: widget.hitHeight + 8,
|
||||
child: ScrubPreviewCard(
|
||||
service: widget.thumbnailService!,
|
||||
episodeId: widget.episodeId,
|
||||
posterUrl: widget.posterUrl,
|
||||
position: previewPosition,
|
||||
duration: video!.duration,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -230,6 +265,12 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
||||
);
|
||||
}
|
||||
widget.thumbnailService?.cancelPending();
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
void _cancelPreview() {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
@@ -264,6 +265,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
showTimes: true,
|
||||
compact: true,
|
||||
locked: data.locked,
|
||||
thumbnailService: data.thumbnailService,
|
||||
episodeId: data.episode.id,
|
||||
posterUrl: data.drama.posterUrl,
|
||||
inactiveTrackColor: Colors.white24,
|
||||
onScrubStart: actions.onScrubStart,
|
||||
onSeek: actions.onSeek,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 播放浮层展示数据。
|
||||
@@ -24,6 +25,7 @@ class PlayerOverlayData {
|
||||
required this.totalEpisodes,
|
||||
required this.boosting,
|
||||
required this.locked,
|
||||
this.thumbnailService,
|
||||
this.authorNote = '',
|
||||
});
|
||||
|
||||
@@ -69,6 +71,9 @@ class PlayerOverlayData {
|
||||
/// 当前剧集是否锁定。
|
||||
final bool locked;
|
||||
|
||||
/// 拖动进度时按当前集和时间点取帧。
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
|
||||
/// 作者声明;没有后端字段时为空。
|
||||
final String authorNote;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
|
||||
const double scrubPreviewWidth = 120;
|
||||
const double scrubPreviewSafeMargin = 12;
|
||||
|
||||
/// 把预览卡片限制在进度条可用宽度内。
|
||||
double scrubPreviewLeft({
|
||||
required double trackWidth,
|
||||
required double fraction,
|
||||
double cardWidth = scrubPreviewWidth,
|
||||
double safeMargin = scrubPreviewSafeMargin,
|
||||
}) {
|
||||
final maxLeft = trackWidth - cardWidth - safeMargin;
|
||||
if (maxLeft < safeMargin) {
|
||||
return ((trackWidth - cardWidth) / 2).clamp(0.0, trackWidth).toDouble();
|
||||
}
|
||||
final requested = trackWidth * fraction.clamp(0.0, 1.0) - cardWidth / 2;
|
||||
return requested.clamp(safeMargin, maxLeft).toDouble();
|
||||
}
|
||||
|
||||
/// 拖动进度时覆盖在轨道上方的单帧预览。
|
||||
class ScrubPreviewCard extends StatefulWidget {
|
||||
const ScrubPreviewCard({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.episodeId,
|
||||
required this.posterUrl,
|
||||
required this.position,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
final PlaybackThumbnailService service;
|
||||
final String episodeId;
|
||||
final String posterUrl;
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
State<ScrubPreviewCard> createState() => _ScrubPreviewCardState();
|
||||
}
|
||||
|
||||
class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
ByteData? _frame;
|
||||
int _requestSerial = 0;
|
||||
int? _requestedBucket;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_requestFrame();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ScrubPreviewCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final bucket = playbackThumbnailBucket(widget.position);
|
||||
if (oldWidget.service != widget.service ||
|
||||
oldWidget.episodeId != widget.episodeId ||
|
||||
_requestedBucket != bucket) {
|
||||
_frame = null;
|
||||
_requestFrame();
|
||||
} else {
|
||||
// 同 bucket 内继续拖动时不重复取帧,但会重置 120ms 预取停顿计时。
|
||||
unawaited(widget.service.thumbnail(widget.episodeId, widget.position));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_requestSerial++;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _requestFrame() {
|
||||
final bucket = playbackThumbnailBucket(widget.position);
|
||||
_requestedBucket = bucket;
|
||||
final serial = ++_requestSerial;
|
||||
final future = widget.service.thumbnail(widget.episodeId, widget.position);
|
||||
future.then((frame) {
|
||||
if (!mounted || serial != _requestSerial) return;
|
||||
setState(() => _frame = frame);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: const Key('scrub-preview-card'),
|
||||
width: scrubPreviewWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
color: Colors.black87,
|
||||
child: Text(
|
||||
'${_format(widget.position)} / ${_format(widget.duration)}',
|
||||
key: const Key('scrub-preview-time'),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
widget.posterUrl,
|
||||
key: const Key('scrub-preview-poster'),
|
||||
fit: BoxFit.cover,
|
||||
fallbackColor: AppColors.bar,
|
||||
),
|
||||
if (_frame case final frame?)
|
||||
Image.memory(
|
||||
frame.buffer.asUint8List(
|
||||
frame.offsetInBytes,
|
||||
frame.lengthInBytes,
|
||||
),
|
||||
key: const Key('scrub-preview-frame'),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 240,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _format(Duration value) {
|
||||
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'loading_progress_track.dart';
|
||||
import 'scrub_preview_card.dart';
|
||||
|
||||
const Key _inactiveTrackKey = Key('progress-inactive');
|
||||
|
||||
@@ -32,6 +34,9 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.compact = false,
|
||||
this.inactiveTrackColor = AppColors.border,
|
||||
this.locked = false,
|
||||
this.thumbnailService,
|
||||
this.episodeId,
|
||||
this.posterUrl = '',
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -42,6 +47,9 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final bool compact;
|
||||
final Color inactiveTrackColor;
|
||||
final bool locked;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
final String? episodeId;
|
||||
final String posterUrl;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -69,6 +77,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_bufferTimer?.cancel();
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_attachSource();
|
||||
}
|
||||
if (oldWidget.locked != widget.locked) {
|
||||
@@ -76,6 +85,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_bufferTimer = null;
|
||||
_buffering = false;
|
||||
if (widget.locked) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_previewFraction.value = null;
|
||||
} else {
|
||||
_syncBufferState(notify: false);
|
||||
@@ -87,6 +97,9 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
void dispose() {
|
||||
_detachSource(widget.valueListenable);
|
||||
_bufferTimer?.cancel();
|
||||
if (_previewFraction.value != null) {
|
||||
widget.thumbnailService?.cancelPending();
|
||||
}
|
||||
_previewFraction.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -148,15 +161,10 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
|
||||
Future<void> _finish(double fraction, Duration duration) async {
|
||||
if (widget.locked) return;
|
||||
final source = widget.valueListenable;
|
||||
_previewFraction.value = fraction;
|
||||
try {
|
||||
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
||||
} finally {
|
||||
if (mounted && widget.valueListenable == source) {
|
||||
_previewFraction.value = null;
|
||||
}
|
||||
}
|
||||
widget.thumbnailService?.cancelPending();
|
||||
_previewFraction.value = null;
|
||||
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -194,6 +202,11 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
final previewPosition = _previewFraction.value == null
|
||||
? video.position
|
||||
: seekPositionForFraction(fraction, video.duration);
|
||||
final showPreview =
|
||||
scrubbing &&
|
||||
widget.showTimes &&
|
||||
widget.thumbnailService != null &&
|
||||
(widget.episodeId?.isNotEmpty ?? false);
|
||||
final trackHeight = widget.compact
|
||||
? (scrubbing ? 3.0 : 1.5)
|
||||
: (scrubbing ? 4.0 : 3.0);
|
||||
@@ -203,72 +216,98 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
? (scrubbing ? 32.0 : 20.0)
|
||||
: (scrubbing ? 48.0 : 44.0);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.showTimes && scrubbing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: trackAreaHeight,
|
||||
child: widget.locked || !video.isInitialized
|
||||
? _inactiveTrack(trackHeight)
|
||||
: loading
|
||||
? Center(
|
||||
child: LoadingProgressTrack(
|
||||
height: trackHeight,
|
||||
trackColor: widget.inactiveTrackColor,
|
||||
),
|
||||
)
|
||||
: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: trackHeight,
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.inactiveTrackColor,
|
||||
thumbColor: AppColors.onPrimary,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: widget.compact
|
||||
? (scrubbing ? 6 : 4)
|
||||
: (scrubbing ? 8 : 6),
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
child: Slider(
|
||||
value: fraction,
|
||||
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
||||
padding: EdgeInsets.zero,
|
||||
onChangeStart: enabled
|
||||
? (value) {
|
||||
_previewFraction.value = value;
|
||||
widget.onScrubStart?.call();
|
||||
}
|
||||
: null,
|
||||
onChanged: enabled
|
||||
? (value) => _previewFraction.value = value
|
||||
: null,
|
||||
onChangeEnd: enabled
|
||||
? (value) => unawaited(_finish(value, video.duration))
|
||||
: null,
|
||||
),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final track = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.showTimes && scrubbing && !showPreview)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
height: trackAreaHeight,
|
||||
child: widget.locked || !video.isInitialized
|
||||
? _inactiveTrack(trackHeight)
|
||||
: loading
|
||||
? Center(
|
||||
child: LoadingProgressTrack(
|
||||
height: trackHeight,
|
||||
trackColor: widget.inactiveTrackColor,
|
||||
),
|
||||
)
|
||||
: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: trackHeight,
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.inactiveTrackColor,
|
||||
thumbColor: AppColors.onPrimary,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: widget.compact
|
||||
? (scrubbing ? 6 : 4)
|
||||
: (scrubbing ? 8 : 6),
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
child: Slider(
|
||||
value: fraction,
|
||||
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
||||
padding: EdgeInsets.zero,
|
||||
onChangeStart: enabled
|
||||
? (value) {
|
||||
_previewFraction.value = value;
|
||||
widget.onScrubStart?.call();
|
||||
}
|
||||
: null,
|
||||
onChanged: enabled
|
||||
? (value) => _previewFraction.value = value
|
||||
: null,
|
||||
onChangeEnd: enabled
|
||||
? (value) =>
|
||||
unawaited(_finish(value, video.duration))
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
track,
|
||||
if (showPreview)
|
||||
Positioned(
|
||||
left: scrubPreviewLeft(
|
||||
trackWidth: constraints.maxWidth,
|
||||
fraction: fraction,
|
||||
),
|
||||
bottom: trackAreaHeight + 8,
|
||||
child: ScrubPreviewCard(
|
||||
service: widget.thumbnailService!,
|
||||
episodeId: widget.episodeId!,
|
||||
posterUrl: widget.posterUrl,
|
||||
position: previewPosition,
|
||||
duration: video.duration,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user