From 222bacdeeb4bf2686fa7ec655b9ef3e1459104fc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 13:15:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E4=BC=98=E5=8C=96=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=BB=91=E5=88=87=E9=9B=86=E9=BB=91=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 177 ++++++++++++++++++ lib/core/data/services/playback_session.dart | 158 ++++++---------- lib/pages/player/player_logic.dart | 22 ++- lib/pages/player/player_view.dart | 10 +- .../player/widgets/player_opening_frame.dart | 167 +++++++++++++++++ .../player/widgets/player_video_surface.dart | 14 +- test/playback/playback_preload_test.dart | 51 ++++- test/player/player_logic_test.dart | 5 +- test/player/player_opening_frame_test.dart | 95 ++++++++++ test/player/player_video_surface_test.dart | 48 ++++- 10 files changed, 625 insertions(+), 122 deletions(-) create mode 100644 lib/core/data/services/playback_neighbors.dart create mode 100644 lib/pages/player/widgets/player_opening_frame.dart create mode 100644 test/player/player_opening_frame_test.dart diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart new file mode 100644 index 0000000..d1aad89 --- /dev/null +++ b/lib/core/data/services/playback_neighbors.dart @@ -0,0 +1,177 @@ +// Author: fruit.com.cn + +part of 'playback_session.dart'; + +/// 当前集加上一集、下一集,切集时最多三个解码器。 +const int maxLivePlayers = 3; + +/// 另一条播放路由必须等当前路由释放完全部原生播放器。 +class PlaybackCoordinator { + PlaybackSession? _owner; + Future _handoff = Future.value(); + + Future _acquire(PlaybackSession session, bool Function() valid) { + final result = _handoff.then((_) async { + if (!valid()) return false; + if (_owner != session) { + final previous = _owner; + await previous?._releasePlayers(); + if (previous != null && previous.livePlayerCount != 0) return false; + _owner = null; + if (!valid()) return false; + _owner = session; + } + return true; + }); + _handoff = result.then((_) {}); + return result; + } + + void _forget(PlaybackSession session) { + if (_owner == session && session.livePlayerCount == 0) _owner = null; + } +} + +_PlayerSlot? _usableWarm( + PlaybackSession session, + _PlayerSlot? slot, + int index, +) { + if (slot == null || slot.index != index || slot.failure != null) return null; + if (session.isLocked) return null; + return slot; +} + +/// 滑走的一集若仍是相邻集,就留着最后一帧,其余槽位释放。 +Future _parkNeighbors( + PlaybackSession session, + _PlayerSlot? old, + _PlayerSlot? warmNext, + _PlayerSlot? warmPrevious, +) async { + await _placeNeighbor(session, old); + await _placeNeighbor(session, warmNext); + await _placeNeighbor(session, warmPrevious); +} + +Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { + if (slot == null || slot == session._active || slot.retiring) return; + if (session._next == null && slot.index == session._nextIndex) { + session._next = slot; + return; + } + if (session._previous == null && slot.index == session._previousIndex) { + session._previous = slot; + return; + } + await session._retire(slot); +} + +/// 当前集已经出画后,静默准备上一集和下一集。 +void _warmNeighbors(PlaybackSession session) { + final request = session._generation; + session._preloadTask = () async { + await Future.wait([ + _warmOne(session, request, session._nextIndex, next: true), + _warmOne(session, request, session._previousIndex, next: false), + ]); + }(); +} + +Future _warmOne( + PlaybackSession session, + int request, + int? index, { + required bool next, +}) async { + final candidate = await _allocateNeighbor( + session, + request, + index, + next: next, + ); + if (candidate == null) return; + await candidate.ready; + if (session._closed || !_stillHeld(session, candidate, next: next)) return; + if (candidate.failure == null) { + session._notify(); + return; + } + await session._enqueue(() async { + if (!_stillHeld(session, candidate, next: next)) return; + _clearNeighbor(session, next: next); + await session._retire(candidate); + }); +} + +Future<_PlayerSlot?> _allocateNeighbor( + PlaybackSession session, + int request, + int? index, { + required bool next, +}) async { + _PlayerSlot? created; + await session._enqueue(() async { + if (session._closed || request != session._generation) return; + final allowed = _neighborAllowed(session, index); + final current = next ? session._next : session._previous; + if (!allowed || current?.index != index) { + _clearNeighbor(session, next: next); + await session._retire(current); + } + if (!_canAllocate(session, request, allowed: allowed, next: next)) { + return; + } + try { + created = session._create(index!); + } on StateError catch (error) { + if (kDebugMode) debugPrint('相邻集预热跳过: $error'); + return; + } + if (next) { + session._next = created; + } else { + session._previous = created; + } + }); + return created; +} + +bool _neighborAllowed(PlaybackSession session, int? index) { + return index != null && + index >= 0 && + index < session.episodes.length && + index != session._currentIndex && + session.canPlay(session.episodes[index]); +} + +bool _canAllocate( + PlaybackSession session, + int request, { + required bool allowed, + required bool next, +}) { + final held = next ? session._next : session._previous; + return session._visible && + !session._overlayOpen && + request == session._generation && + session._active?.prepared == true && + allowed && + held == null; +} + +bool _stillHeld( + PlaybackSession session, + _PlayerSlot slot, { + required bool next, +}) { + return (next ? session._next : session._previous) == slot; +} + +void _clearNeighbor(PlaybackSession session, {required bool next}) { + if (next) { + session._next = null; + return; + } + session._previous = null; +} diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 61de1cb..04d9a0c 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -7,6 +7,8 @@ import '../models/drama.dart'; import '../models/player_port.dart'; import 'video_player_port.dart'; +part 'playback_neighbors.dart'; + /// 新纹理第一帧常是黑的,旧画面只盖住这一小段。 const Duration _qualityHandoffHold = Duration(milliseconds: 200); @@ -14,37 +16,7 @@ typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerPreparation = Future Function(Episode episode, PlayerPort player); -/// Shared by routes: the previous session releases all native players before -/// another session allocates. The app therefore has at most two, not two per page. -class PlaybackCoordinator { - PlaybackSession? _owner; - Future _handoff = Future.value(); - - Future _acquire(PlaybackSession session, bool Function() valid) { - final result = _handoff.then((_) async { - if (!valid()) return false; - if (_owner != session) { - final previous = _owner; - await previous?._releasePlayers(); - // A failed native disposal cannot be treated as a free decoder slot. - if (previous != null && previous.livePlayerCount != 0) return false; - _owner = null; - if (!valid()) return false; - _owner = session; - } - return true; - }); - _handoff = result.then((_) {}); - return result; - } - - void _forget(PlaybackSession session) { - if (_owner == session && session.livePlayerCount == 0) _owner = null; - } -} - -/// Owns current + one prepared successor. All allocation, playback and disposal -/// operations are serialized; initialization can run while current plays. +/// Owns current plus previous/next frames; allocation stays serialized. class PlaybackSession extends ChangeNotifier { PlaybackSession({ required this.episodes, @@ -70,12 +42,14 @@ class PlaybackSession extends ChangeNotifier { final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; _PlayerSlot? _next; + _PlayerSlot? _previous; _PlayerSlot? _handoff; Future _operations = Future.value(); Future _preloadTask = Future.value(); int _generation = 0; int _currentIndex = 0; int? _nextIndex; + int? _previousIndex; bool _selected = false; bool _loading = false; bool _closed = false; @@ -120,7 +94,7 @@ class PlaybackSession extends ChangeNotifier { /// during a swipe without starting audio or registering history listeners. PlayerPort? preparedPlayerAt(int index) { if (_closed) return null; - for (final slot in [_active, _next]) { + for (final slot in [_active, _next, _previous]) { if (slot == null || slot.retiring || slot.index != index || @@ -142,24 +116,36 @@ class PlaybackSession extends ChangeNotifier { } /// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。 - void remapCurrentIndex(int index, {int? preloadIndex}) { + void remapCurrentIndex(int index, {int? preloadIndex, int? previousIndex}) { if (_closed || index < 0 || index >= episodes.length) return; final activeSlot = _active; if (activeSlot == null || activeSlot.index != _currentIndex) { _currentIndex = index; - _nextIndex = preloadIndex; + _rememberNeighbors(preloadIndex, previousIndex); _notify(); return; } _generation++; activeSlot.index = index; _currentIndex = index; - _nextIndex = preloadIndex; + _rememberNeighbors(preloadIndex, previousIndex); final staleNext = _next; + final stalePrevious = _previous; _next = null; + _previous = null; _notify(); - unawaited(_enqueue(() async => _retire(staleNext))); - if (_visible && !_overlayOpen) _schedulePreload(); + unawaited( + _enqueue(() async { + await _retire(staleNext); + await _retire(stalePrevious); + }), + ); + if (_visible && !_overlayOpen) _warmNeighbors(this); + } + + void _rememberNeighbors(int? preloadIndex, int? previousIndex) { + _nextIndex = preloadIndex; + _previousIndex = previousIndex; } Future _enqueue(Future Function() operation) { @@ -176,7 +162,9 @@ class PlaybackSession extends ChangeNotifier { } _PlayerSlot _create(int index) { - if (_live.length >= 2) throw StateError('Playback resource limit reached'); + if (_live.length >= maxLivePlayers) { + throw StateError('Playback resource limit reached'); + } final slot = _PlayerSlot(index, factory(episodes[index])); _live.add(slot); slot.ready = () async { @@ -198,9 +186,14 @@ class PlaybackSession extends ChangeNotifier { Future _retire(_PlayerSlot? slot) async { if (slot == null || !_live.contains(slot)) return; slot.retiring = true; - final cleared = _active == slot || _next == slot || _handoff == slot; + final cleared = + _active == slot || + _next == slot || + _previous == slot || + _handoff == slot; if (_active == slot) _active = null; if (_next == slot) _next = null; + if (_previous == slot) _previous = null; if (_handoff == slot) _handoff = null; if (cleared) _notify(); await waitForFrame?.call(); @@ -215,7 +208,11 @@ class PlaybackSession extends ChangeNotifier { /// preloadIndex comes from the visible queue, so filtered categories and series /// use exactly the same mechanism without warming an unrelated episode. - Future select(int index, {int? preloadIndex}) async { + Future select( + int index, { + int? preloadIndex, + int? previousIndex, + }) async { if (_closed || index < 0 || index >= episodes.length) return; // 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。 // 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。 @@ -225,7 +222,7 @@ class PlaybackSession extends ChangeNotifier { final request = ++_generation; _selected = true; _currentIndex = index; - _nextIndex = preloadIndex; + _rememberNeighbors(preloadIndex, previousIndex); _error = null; if (!_visible) { _loading = false; @@ -260,19 +257,22 @@ class PlaybackSession extends ChangeNotifier { return; } if (old != null) await old.player.pause(); - final warm = _next; + final warmNext = _next; + final warmPrevious = _previous; _active = null; _next = null; - final hit = warm?.index == index && warm?.failure == null && !isLocked; - if (hit) { + _previous = null; + final warm = + _usableWarm(this, warmNext, index) ?? + _usableWarm(this, warmPrevious, index); + if (warm != null) { target = _active = warm; - _loading = !warm!.prepared; + _loading = !warm.prepared; _notify(); // Start a ready successor before awaiting slow disposal of the old one. if (warm.prepared) await _start(warm, request); } - await _retire(old); - if (!hit) await _retire(warm); + await _parkNeighbors(this, old, warmNext, warmPrevious); if (_closed || request != _generation || isLocked) return; if (target == null) { target = _active = _create(index); @@ -285,7 +285,7 @@ class PlaybackSession extends ChangeNotifier { await slot.ready; if (_closed || request != _generation || _active != slot) return; await _enqueue(() => _start(slot, request)); - if (!_closed && request == _generation) _schedulePreload(); + if (!_closed && request == _generation) _warmNeighbors(this); } /// 后台准备新片源,成功后再替换当前播放器。失败时原播放器保持不动。 @@ -316,7 +316,7 @@ class PlaybackSession extends ChangeNotifier { if (previous != null && _handoff == previous && _active == slot) { await _enqueue(() => _retire(previous)); } - if (!_closed && request == _generation) _schedulePreload(); + if (!_closed && request == _generation) _warmNeighbors(this); return _active == slot && slot.failure == null; } @@ -422,54 +422,6 @@ class PlaybackSession extends ChangeNotifier { slot.playing = shouldPlay; } - void _schedulePreload() { - final request = _generation; - final index = _nextIndex; - _preloadTask = () async { - _PlayerSlot? candidate; - await _enqueue(() async { - if (_closed || request != _generation) return; - final allowed = - index != null && - index >= 0 && - index < episodes.length && - index != _currentIndex && - canPlay(episodes[index]); - if (!allowed || _next?.index != index) { - final stale = _next; - _next = null; - await _retire(stale); - } - if (!_visible || - _overlayOpen || - request != _generation || - _active?.prepared != true || - !allowed) { - return; - } - try { - candidate = _next ??= _create(index); - } catch (_) { - // Optional allocation failure must not interrupt the playing item. - } - }); - final slot = candidate; - if (slot == null) return; - await slot.ready; - if (_closed || _next != slot) return; - if (slot.failure != null) { - // Optional work fails silently. A later explicit selection retries. - await _enqueue(() async { - if (_next != slot) return; - _next = null; - await _retire(slot); - }); - } else { - _notify(); - } - }(); - } - void setAppResumed(bool value) { _appResumed = value; _syncPlayback(); @@ -494,18 +446,24 @@ class PlaybackSession extends ChangeNotifier { void _syncPlayback() { if (_closed) return; if (_visible && _selected && active == null) { - unawaited(select(_currentIndex, preloadIndex: _nextIndex)); + unawaited( + select( + _currentIndex, + preloadIndex: _nextIndex, + previousIndex: _previousIndex, + ), + ); return; } final slot = _active; if (slot != null) unawaited(_enqueue(() => _applyPlayback(slot))); - if (_visible && !_overlayOpen && !_loading) _schedulePreload(); + if (_visible && !_overlayOpen && !_loading) _warmNeighbors(this); } Future _releasePlayers() { _generation++; return _enqueue(() async { - _active = _next = _handoff = null; + _active = _next = _previous = _handoff = null; _loading = false; _notify(); for (final slot in _live.toList()) { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 6c47780..b22aea0 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -299,6 +299,7 @@ class PlayerLogic extends ChangeNotifier { session.remapCurrentIndex( keepIndex, preloadIndex: nextVisibleIndex(keepIndex), + previousIndex: previousVisibleIndex(keepIndex), ); _jumpToFeedPage(keepIndex); _notifyAfterFrameIfNeeded(); @@ -530,6 +531,7 @@ class PlayerLogic extends ChangeNotifier { if (done.isCompleted) return; if (value.hasError || !value.isBuffering) done.complete(); } + controller.addListener(onChange); try { await done.future.timeout(const Duration(seconds: 4)); @@ -566,7 +568,7 @@ class PlayerLogic extends ChangeNotifier { } final ready = await ensureTicket(index, forceRefresh: true); if (ready) { - await session.select(index, preloadIndex: nextVisibleIndex(index)); + await _selectVisible(index); } return ready; } finally { @@ -968,7 +970,16 @@ class PlayerLogic extends ChangeNotifier { return; } if (!ready && ticketError != null) return; - await session.select(index, preloadIndex: nextVisibleIndex(index)); + await _selectVisible(index); + } + + /// 按可见队列选择一集,并预热上一集与下一集。 + Future _selectVisible(int index) { + return session.select( + index, + preloadIndex: nextVisibleIndex(index), + previousIndex: previousVisibleIndex(index), + ); } int? nextVisibleIndex(int index) { @@ -978,6 +989,13 @@ class PlayerLogic extends ChangeNotifier { : null; } + /// 可见队列里当前集的上一集;已经是第一集时为空。 + int? previousVisibleIndex(int index) { + final position = visibleIndices.indexOf(index); + if (position <= 0) return null; + return visibleIndices[position - 1]; + } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; Future playNextEpisode() async { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f111dee..96e5e00 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -41,6 +41,7 @@ import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; import 'widgets/player_speed_sheet.dart'; import 'widgets/player_unlock_sheet.dart'; +import 'widgets/player_opening_frame.dart'; import 'widgets/player_video_surface.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -552,7 +553,9 @@ class _PlayerPageState extends State final ready = videoReady || port != null; final theater = logic.pip || logic.pipPreparing; final locked = logic.session.isLocked || episode.locked; - if (selected && videoPort != null && videoPort.controller.value.isInitialized) { + if (selected && + videoPort != null && + videoPort.controller.value.isInitialized) { unawaited( _spriteAxisService.prepareAt( episode.thumbnailsUrl, @@ -574,6 +577,7 @@ class _PlayerPageState extends State handoff: handoffPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, + openingFrame: playerOpeningFrame(episode.thumbnailsUrl), ), if (selected && !theater && @@ -726,7 +730,9 @@ class _PlayerPageState extends State }, onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, - onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) : null, + onScrubStart: videoReady + ? () => logic.beginScrub(videoPort!) + : null, onScrubEnd: _finishFeedScrub, onScrubVisibilityChanged: _setScrubbing, cancelScrubRequest: widget.scrubCancelRequest, diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart new file mode 100644 index 0000000..b445af1 --- /dev/null +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -0,0 +1,167 @@ +// Author: fruit.com.cn + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import '../../../core/config/app_config.dart'; +import '../../../core/data/services/sprite_thumbnail_asset_loader.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; +import 'scrub_preview_card.dart'; + +/// 无雪碧图地址时不垫帧,交还给纯黑底。 +Widget? playerOpeningFrame(String? thumbnailsUrl) { + final url = thumbnailsUrl?.trim() ?? ''; + if (url.isEmpty) return null; + return PlayerOpeningFrame(thumbnailsUrl: url); +} + +/// 解码出视频画面前,用本集雪碧图第一帧垫底。 +class PlayerOpeningFrame extends StatefulWidget { + const PlayerOpeningFrame({ + super.key, + required this.thumbnailsUrl, + this.loader, + this.cache, + }); + + final String thumbnailsUrl; + final SpriteThumbnailAssetLoader? loader; + final OpeningFrameCache? cache; + + @override + State createState() => _PlayerOpeningFrameState(); +} + +class _PlayerOpeningFrameState extends State { + SpriteThumbnailFrame? _frame; + int _serial = 0; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + @override + void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { + _frame = null; + unawaited(_load()); + } + } + + Future _load() async { + final serial = ++_serial; + final frame = await (widget.cache ?? OpeningFrameCache.shared).load( + widget.thumbnailsUrl, + loader: widget.loader, + ); + if (!mounted || serial != _serial) return; + setState(() => _frame = frame); + } + + @override + Widget build(BuildContext context) { + final frame = _frame; + if (frame == null) return const SizedBox.expand(); + return SpriteCropPreview( + key: const Key('player-opening-sprite'), + image: frame.image, + sourceRect: frame.sourceRect, + ); + } +} + +/// 相邻页共用的雪碧图首帧缓存,避免每页重复拉 VTT。 +class OpeningFrameCache { + OpeningFrameCache({this.capacity = 6}); + + static final OpeningFrameCache shared = OpeningFrameCache(); + + final int capacity; + final Map _frames = {}; + final Map> _loads = {}; + + /// 取 [thumbnailsUrl] 对应的第一帧;失败返回 null。 + Future load( + String? thumbnailsUrl, { + SpriteThumbnailAssetLoader? loader, + }) { + final url = _normalize(thumbnailsUrl); + if (url == null) return Future.value(); + final cached = _frames[url]; + if (cached != null) return Future.value(cached); + return _loads.putIfAbsent(url, () => _fetch(url, loader)); + } + + Future _fetch( + String url, + SpriteThumbnailAssetLoader? loader, + ) async { + try { + final asset = loader ?? DioSpriteThumbnailAssetLoader(); + final text = await asset.loadVtt(Uri.parse(url)); + if (text == null || text.isEmpty) return null; + final axis = parseSpriteThumbnailVtt(text, vttUrl: url); + final cue = axis.cues.isEmpty ? null : axis.cues.first; + if (cue == null) return null; + final decoded = await asset.loadSprite( + axis.resolveSpriteUri(cue.spriteFile), + ); + if (decoded == null) return null; + final frame = SpriteThumbnailFrame( + image: decoded.image, + sourceRect: _scaledRect(decoded, cue.sourceRect), + spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(), + cue: cue, + ); + _store(url, frame); + return frame; + } catch (_) { + return null; + } finally { + _loads.remove(url); + } + } + + void _store(String url, SpriteThumbnailFrame frame) { + _frames.remove(url); + _frames[url] = frame; + while (_frames.length > capacity) { + final oldest = _frames.keys.first; + _frames.remove(oldest)?.image.dispose(); + } + } + + ui.Rect _scaledRect(SpriteThumbnailDecodedImage decoded, ui.Rect source) { + if (decoded.originalWidth <= 0 || decoded.originalHeight <= 0) { + return source; + } + final scaleX = decoded.image.width / decoded.originalWidth; + final scaleY = decoded.image.height / decoded.originalHeight; + return ui.Rect.fromLTWH( + source.left * scaleX, + source.top * scaleY, + source.width * scaleX, + source.height * scaleY, + ); + } + + String? _normalize(String? raw) { + final value = AppConfig.mediaUrl(raw?.trim() ?? ''); + if (value.isEmpty) return null; + return value; + } + + /// 测试收尾时清空缓存。 + void clear() { + for (final frame in _frames.values) { + frame.image.dispose(); + } + _frames.clear(); + _loads.clear(); + } +} diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 62bfb64..69890f9 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -18,10 +18,14 @@ class PlayerVideoSurface extends StatefulWidget { required this.videoUrl, required this.theater, this.handoff, + this.openingFrame, }); final VideoPlayerController? controller; final VideoPlayerController? handoff; + + /// 解码出第一帧之前垫上的视频首帧,没有则为空。 + final Widget? openingFrame; final String videoUrl; final bool theater; @@ -60,6 +64,11 @@ class _PlayerVideoSurfaceState extends State { key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), + if (!reveal && widget.openingFrame != null) + KeyedSubtree( + key: const Key('player-opening-frame'), + child: widget.openingFrame!, + ), AnimatedOpacity( key: const Key('player-video-opacity'), opacity: reveal ? 1 : 0, @@ -99,10 +108,9 @@ class _PlayerVideoSurfaceState extends State { } bool _shouldReveal(VideoPlayerValue? value, bool covered) { + if (covered) return value?.isInitialized ?? false; if (value == null || value.hasError || !value.isInitialized) return false; - if (value.size.width <= 0) return false; - if (covered) return true; - return value.position > Duration.zero || value.isPlaying; + return value.size.width > 0 && value.size.height > 0; } /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 582494b..1c1dff7 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -91,20 +91,63 @@ void main() { expect(session.preloadedIndex, 1); final loadingStates = []; session.addListener(() => loadingStates.add(session.loading)); - await session.select(1, preloadIndex: 2); + await session.select(1, preloadIndex: 2, previousIndex: 0); await session.preloadSettled; expect(session.active, same(warm)); expect(warm.initializeCalls, 1); expect(warm.playCalls, 1); expect(loadingStates, isNot(contains(true))); expect(activated, ['ep-0', 'ep-1']); - expect(factory.peakLive, 2); + expect(session.preparedPlayerAt(0), isNotNull); + expect(session.preparedPlayerAt(2), isNotNull); + expect(factory.peakLive, lessThanOrEqualTo(3)); expect(factory.peakPlaying, 1); await session.close(); expect(factory.live, 0); }, ); + test('should_prepare_previous_neighbor_when_selected', () async { + final factory = _Factory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + expect(session.preparedPlayerAt(0), isNotNull); + expect(session.preparedPlayerAt(2), isNotNull); + expect(factory.players.map((player) => player.id).toSet(), { + 'ep-0', + 'ep-1', + 'ep-2', + }); + expect(factory.peakLive, 3); + await session.close(); + }); + + test('should_warm_previous_without_waiting_for_slow_next', () async { + final nextGate = Completer(); + final factory = _Factory(gates: {'ep-2': nextGate}); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await _tick(); + + expect(session.preparedPlayerAt(0), isNotNull); + expect(session.preparedPlayerAt(2), isNull); + + nextGate.complete(); + await session.preloadSettled; + expect(session.preparedPlayerAt(2), isNotNull); + await session.close(); + }); + test('should_reuse_in_flight_preload_when_swiped_before_ready', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); @@ -124,7 +167,7 @@ void main() { await session.preloadSettled; expect(session.active, same(warm)); expect(warm.initializeCalls, 1); - expect(factory.peakLive, lessThanOrEqualTo(2)); + expect(factory.peakLive, lessThanOrEqualTo(3)); expect(factory.peakPlaying, 1); await session.close(); }); @@ -149,7 +192,7 @@ void main() { expect(session.preloadedIndex, 2); expect(stale.playCalls, 0); expect(stale.disposed, isTrue); - expect(factory.peakLive, lessThanOrEqualTo(2)); + expect(factory.peakLive, lessThanOrEqualTo(3)); expect(factory.peakPlaying, 1); await session.close(); }); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 4aadab2..e9498ac 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -594,8 +594,9 @@ void main() { await _drain(logic); expect(logic.session.currentIndex, 2); - // The last episode has no successor to warm. - expect(factory.created, hasLength(1)); + // 最后一集没有下一集,但仍会预热上一集。 + expect(factory.created, hasLength(2)); + expect(logic.session.preparedPlayerAt(1), isNotNull); logic.dispose(); await _drain(logic); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart new file mode 100644 index 0000000..fd166de --- /dev/null +++ b/test/player/player_opening_frame_test.dart @@ -0,0 +1,95 @@ +// Author: fruit.com.cn + +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart'; +import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart'; + +const String _spriteVtt = ''' +WEBVTT + +00:00:00.000 --> 00:00:01.000 +sprite_001.jpg#xywh=0,0,160,90 +'''; + +void main() { + test('should_return_no_opening_frame_when_thumbnails_url_is_empty', () { + expect(playerOpeningFrame(null), isNull); + expect(playerOpeningFrame(' '), isNull); + }); + + testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async { + final loader = _FakeSpriteLoader(); + final cache = OpeningFrameCache(); + addTearDown(cache.clear); + + final first = await cache.load( + 'https://cdn.test/thumbnails.vtt', + loader: loader, + ); + final second = await cache.load( + 'https://cdn.test/thumbnails.vtt', + loader: loader, + ); + + expect(first, isNotNull); + expect(second, same(first)); + expect(loader.vttCalls, 1); + expect(loader.spriteCalls, ['sprite_001.jpg']); + }); + + testWidgets('should_render_decoded_opening_frame', (tester) async { + final loader = _FakeSpriteLoader(); + final cache = OpeningFrameCache(); + addTearDown(cache.clear); + + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame( + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + loader: loader, + cache: cache, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); + }); +} + +class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { + int vttCalls = 0; + final List spriteCalls = []; + + @override + Future loadVtt(Uri uri) async { + vttCalls++; + return _spriteVtt; + } + + @override + Future loadSprite(Uri uri) async { + spriteCalls.add(uri.pathSegments.last); + return SpriteThumbnailDecodedImage( + image: await _createImage(320, 180), + originalWidth: 320, + originalHeight: 180, + ); + } +} + +Future _createImage(int width, int height) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = Colors.blue, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + return image; +} diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 6c647f2..6ec0315 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -13,16 +13,48 @@ void main() { await _pumpSurface(tester); expect(_fallbackColor(tester), Colors.black); - expect(find.byKey(const Key('player-fallback-cover')), findsNothing); - expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); + expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect(_opacity(tester), 0); - expect(find.byKey(const Key('player-debug-video')), findsNothing); + }); + + testWidgets('should_show_opening_frame_before_decode_is_ready', ( + tester, + ) async { + await _pumpSurface( + tester, + openingFrame: const ColoredBox( + key: Key('fake-opening'), + color: Colors.red, + ), + ); + + expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); + expect(find.byKey(const Key('fake-opening')), findsOneWidget); + expect(_opacity(tester), 0); + }); + + testWidgets('should_reveal_paused_frame_when_initialized', (tester) async { + final controller = await _pumpSurface(tester); + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration.zero, + isInitialized: true, + isPlaying: false, + ); + await tester.pump(); + + expect(_opacity(tester), 1); + expect(find.byKey(const Key('player-opening-frame')), findsNothing); }); testWidgets('should_fade_video_in_within_200ms_after_first_frame', ( tester, ) async { - final controller = await _pumpSurface(tester); + final controller = await _pumpSurface( + tester, + openingFrame: const ColoredBox(color: Colors.red), + ); expect(_opacity(tester), 0); controller.value = const VideoPlayerValue( @@ -35,6 +67,7 @@ void main() { await tester.pump(); expect(_opacity(tester), 1); + expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect( tester .widget( @@ -49,9 +82,6 @@ void main() { expect(halfway, lessThan(1)); await tester.pump(const Duration(milliseconds: 100)); expect(_fadeValue(tester), 1); - expect(_fallbackColor(tester), Colors.black); - expect(find.byKey(const Key('player-fallback-cover')), findsNothing); - expect(find.byKey(const Key('player-debug-video')), findsNothing); }); testWidgets('should_show_video_error_above_retained_fallback', ( @@ -65,9 +95,7 @@ void main() { expect(find.byKey(const Key('player-video-error')), findsOneWidget); expect(find.text('No route to host'), findsOneWidget); expect(_fallbackColor(tester), Colors.black); - expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(_opacity(tester), 0); - expect(find.byKey(const Key('player-debug-video')), findsNothing); }); testWidgets('should_cover_new_video_only_while_handoff_is_held', ( @@ -119,6 +147,7 @@ void main() { Future _pumpSurface( WidgetTester tester, { VideoPlayerValue? value, + Widget? openingFrame, }) async { final controller = VideoPlayerController.networkUrl( Uri.parse('http://192.168.31.187:8081/files/video.mp4'), @@ -132,6 +161,7 @@ Future _pumpSurface( controller: controller, videoUrl: controller.dataSource, theater: false, + openingFrame: openingFrame, ), ), ),