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 01/42] =?UTF-8?q?fix(player):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=8A=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, ), ), ), From df36890b484f470aebf2b21924f242c5db46b0aa Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 13:43:38 +0800 Subject: [PATCH 02/42] =?UTF-8?q?chore(analyze):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E9=9D=99=E6=80=81=E6=A3=80=E6=9F=A5=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/all_dramas_controller.dart | 2 +- lib/pages/auth/phone_auth_sheet.dart | 1 - test/discover/home_all_dramas_grid_test.dart | 1 - test/home/home_grid_layout_test.dart | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/core/data/services/all_dramas_controller.dart b/lib/core/data/services/all_dramas_controller.dart index ac8047b..78f590c 100644 --- a/lib/core/data/services/all_dramas_controller.dart +++ b/lib/core/data/services/all_dramas_controller.dart @@ -66,7 +66,7 @@ class AllDramasController { this.categories = const [], this.tags = const [], this.dramas = const [], - }) : _api = api; + }) : _api = api; // ignore: prefer_initializing_formals final AllDramasRemote? _api; List classifications; diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 41d16e6..9fe651b 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -8,7 +8,6 @@ import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; -import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 451b340..a68dad5 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -13,7 +13,6 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; -import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:fruitshorts/pages/discover/widgets/discover_category_tab.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 89505c1..c959c6d 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -12,7 +12,6 @@ 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/theme/app_dimens.dart'; -import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; From 516593499c13876fdc36fbe14dd9056f24b3c8bf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 13:43:43 +0800 Subject: [PATCH 03/42] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=AB=96=E6=BB=91=E4=B8=8A=E4=B8=80=E9=9B=86=E4=B8=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 20 +++-- lib/core/data/services/playback_session.dart | 47 +++-------- .../services/playback_session_controls.dart | 54 +++++++++++++ lib/pages/player/player_logic.dart | 2 +- lib/pages/player/player_view.dart | 78 ++++++++++++++----- .../player/widgets/player_opening_frame.dart | 50 ++++++++---- test/playback/playback_preload_test.dart | 58 ++++++++++++++ test/playback/seek_progress_bar_test.dart | 2 +- test/player/player_fullscreen_test.dart | 6 +- test/player/player_opening_frame_test.dart | 27 ++++++- 10 files changed, 264 insertions(+), 80 deletions(-) create mode 100644 lib/core/data/services/playback_session_controls.dart diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index d1aad89..17d67d1 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -67,15 +67,19 @@ Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { await session._retire(slot); } -/// 当前集已经出画后,静默准备上一集和下一集。 -void _warmNeighbors(PlaybackSession session) { +/// 当前集已经出画后,按手势方向静默准备上一集和下一集。 +void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) { final request = session._generation; - session._preloadTask = () async { - await Future.wait([ - _warmOne(session, request, session._nextIndex, next: true), - _warmOne(session, request, session._previousIndex, next: false), - ]); - }(); + final nextTask = _warmOne(session, request, session._nextIndex, next: true); + final previousTask = _warmOne( + session, + request, + session._previousIndex, + next: false, + ); + session._preloadTask = Future.wait( + preferredNext ? [nextTask, previousTask] : [previousTask, nextTask], + ).then((_) {}); } Future _warmOne( diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 04d9a0c..a7266e7 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -8,6 +8,7 @@ import '../models/player_port.dart'; import 'video_player_port.dart'; part 'playback_neighbors.dart'; +part 'playback_session_controls.dart'; /// 新纹理第一帧常是黑的,旧画面只盖住这一小段。 const Duration _qualityHandoffHold = Duration(milliseconds: 200); @@ -40,6 +41,7 @@ class PlaybackSession extends ChangeNotifier { final Future Function()? waitForFrame; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; + final Set _pausedEpisodeIds = {}; _PlayerSlot? _active; _PlayerSlot? _next; _PlayerSlot? _previous; @@ -106,15 +108,6 @@ class PlaybackSession extends ChangeNotifier { return null; } - /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 - PlayerPort? handoffAt(int index) { - final slot = _handoff; - if (_closed || slot == null || slot.retiring || slot.index != index) { - return null; - } - return slot.player; - } - /// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。 void remapCurrentIndex(int index, {int? preloadIndex, int? previousIndex}) { if (_closed || index < 0 || index >= episodes.length) return; @@ -214,10 +207,9 @@ class PlaybackSession extends ChangeNotifier { int? previousIndex, }) async { if (_closed || index < 0 || index >= episodes.length) return; - // 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。 - // 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。 + // 新集默认起播;用户手动暂停过的同一集滑回时继续尊重暂停。 if (!_selected || index != _currentIndex) { - _userWantsPlay = true; + _userWantsPlay = !_pausedEpisodeIds.contains(episodes[index].id); } final request = ++_generation; _selected = true; @@ -256,7 +248,10 @@ class PlaybackSession extends ChangeNotifier { target = old; return; } - if (old != null) await old.player.pause(); + if (old != null) { + await old.player.pause(); + old.playing = false; + } final warmNext = _next; final warmPrevious = _previous; _active = null; @@ -369,7 +364,10 @@ class PlaybackSession extends ChangeNotifier { _handoff = previous; _active = slot; shown = true; - if (previous != null) await previous!.player.pause(); + if (previous != null) { + await previous!.player.pause(); + previous!.playing = false; + } _notify(); }); if (!shown || _active != slot) return null; @@ -422,27 +420,6 @@ class PlaybackSession extends ChangeNotifier { slot.playing = shouldPlay; } - void setAppResumed(bool value) { - _appResumed = value; - _syncPlayback(); - } - - void setRouteVisible(bool value) { - _routeVisible = value; - _syncPlayback(); - } - - void setOverlayOpen(bool value) { - _overlayOpen = value; - _syncPlayback(); - } - - void setUserWantsPlay(bool value) { - _userWantsPlay = value; - _syncPlayback(); - _notify(); - } - void _syncPlayback() { if (_closed) return; if (_visible && _selected && active == null) { diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart new file mode 100644 index 0000000..dd9175b --- /dev/null +++ b/lib/core/data/services/playback_session_controls.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +part of 'playback_session.dart'; + +/// 播放会话的可见性、用户意图与相邻集预热控制。 +extension PlaybackSessionControls on PlaybackSession { + /// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。 + void prewarmNeighbors({required bool forward}) { + if (!_visible || _overlayOpen || _active?.prepared != true) return; + _warmNeighbors(this, preferredNext: forward); + } + + /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 + PlayerPort? handoffAt(int index) { + final slot = _handoff; + if (_closed || slot == null || slot.retiring || slot.index != index) { + return null; + } + return slot.player; + } + + /// 同步应用前台状态并恢复或暂停当前集。 + void setAppResumed(bool value) { + _appResumed = value; + _syncPlayback(); + } + + /// 同步播放器路由是否可见。 + void setRouteVisible(bool value) { + _routeVisible = value; + _syncPlayback(); + } + + /// 同步浮层是否遮挡播放器。 + void setOverlayOpen(bool value) { + _overlayOpen = value; + _syncPlayback(); + } + + /// 记录用户播放意图;手动暂停的剧集滑回时不再自动起播。 + void setUserWantsPlay(bool value) { + if (episodes.isNotEmpty) { + final episodeId = episodes[_currentIndex].id; + if (value) { + _pausedEpisodeIds.remove(episodeId); + } else { + _pausedEpisodeIds.add(episodeId); + } + } + _userWantsPlay = value; + _syncPlayback(); + _notify(); + } +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index b22aea0..07b450a 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -969,7 +969,7 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } - if (!ready && ticketError != null) return; + if (!ready) return; await _selectVisible(index); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 96e5e00..7b07f04 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -124,6 +125,8 @@ class _PlayerPageState extends State double? _speedBeforeBoost; bool? _chromeBeforeBoost; DateTime? _lastDoubleTapAt; + DateTime? _swipeStartedAt; + bool _swipePrewarmCaptured = false; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -308,6 +311,34 @@ class _PlayerPageState extends State unawaited(logic.setPlaybackSpeed(speed)); } + bool _handlePageScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + _swipeStartedAt = DateTime.now(); + _swipePrewarmCaptured = false; + } else if (notification is ScrollUpdateNotification && + !_swipePrewarmCaptured && + notification.dragDetails != null) { + final delta = notification.scrollDelta ?? 0; + if (delta != 0) { + _swipePrewarmCaptured = true; + logic.session.prewarmNeighbors(forward: delta > 0); + } + } else if (notification is ScrollEndNotification) { + _swipePrewarmCaptured = false; + } + return false; + } + + void _logSwipeReveal() { + if (!kDebugMode) return; + final startedAt = _swipeStartedAt; + if (startedAt == null) return; + _swipeStartedAt = null; + final elapsed = DateTime.now().difference(startedAt).inMilliseconds; + debugPrint('[player] swipe reveal ${elapsed}ms'); + } + Future _refreshHomeFeed() async { final refresh = widget.onRefreshFeed; if (refresh == null) return; @@ -420,25 +451,28 @@ class _PlayerPageState extends State final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { - final pages = PageView.builder( - controller: logic.pages, - scrollDirection: Axis.vertical, - allowImplicitScrolling: true, - physics: widget.onRefreshFeed == null - ? null - : const FeedRefreshPhysics(), - itemCount: indices.length, - onPageChanged: (position) { - final index = indices[position]; - widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(logic.selectEpisode(index)); - if (position >= indices.length - 3) { - final loadMore = widget.onLoadMore; - if (loadMore != null) unawaited(loadMore()); - } - }, - itemBuilder: (context, position) => - _buildEpisode(context, indices[position], interactions), + final pages = NotificationListener( + onNotification: _handlePageScroll, + child: PageView.builder( + controller: logic.pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + physics: widget.onRefreshFeed == null + ? null + : const FeedRefreshPhysics(), + itemCount: indices.length, + onPageChanged: (position) { + final index = indices[position]; + widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); + unawaited(logic.selectEpisode(index)); + if (position >= indices.length - 3) { + final loadMore = widget.onLoadMore; + if (loadMore != null) unawaited(loadMore()); + } + }, + itemBuilder: (context, position) => + _buildEpisode(context, indices[position], interactions), + ), ); if (widget.onRefreshFeed == null) return pages; return FeedPullRefresh( @@ -550,6 +584,7 @@ class _PlayerPageState extends State final handoffPort = handoff is VideoPlayerPort ? handoff : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; + if (selected && videoReady) _logSwipeReveal(); final ready = videoReady || port != null; final theater = logic.pip || logic.pipPreparing; final locked = logic.session.isLocked || episode.locked; @@ -577,7 +612,10 @@ class _PlayerPageState extends State handoff: handoffPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - openingFrame: playerOpeningFrame(episode.thumbnailsUrl), + openingFrame: playerOpeningFrame( + episode.thumbnailsUrl, + posterUrl: drama.posterUrl, + ), ), if (selected && !theater && diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index b445af1..594c456 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -8,25 +8,33 @@ 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 '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; -/// 无雪碧图地址时不垫帧,交还给纯黑底。 -Widget? playerOpeningFrame(String? thumbnailsUrl) { +/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 +Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) { final url = thumbnailsUrl?.trim() ?? ''; - if (url.isEmpty) return null; - return PlayerOpeningFrame(thumbnailsUrl: url); + final poster = posterUrl?.trim() ?? ''; + if (url.isEmpty && poster.isEmpty) return null; + return PlayerOpeningFrame( + thumbnailsUrl: url.isEmpty ? null : url, + posterUrl: poster.isEmpty ? null : poster, + ); } -/// 解码出视频画面前,用本集雪碧图第一帧垫底。 +/// 视频解码前依次使用封面和雪碧图首帧,最终由父层黑色兜底。 class PlayerOpeningFrame extends StatefulWidget { const PlayerOpeningFrame({ super.key, - required this.thumbnailsUrl, + this.thumbnailsUrl, + this.posterUrl, this.loader, this.cache, }); - final String thumbnailsUrl; + final String? thumbnailsUrl; + final String? posterUrl; final SpriteThumbnailAssetLoader? loader; final OpeningFrameCache? cache; @@ -55,8 +63,10 @@ class _PlayerOpeningFrameState extends State { Future _load() async { final serial = ++_serial; + final thumbnailsUrl = widget.thumbnailsUrl; + if (thumbnailsUrl == null) return; final frame = await (widget.cache ?? OpeningFrameCache.shared).load( - widget.thumbnailsUrl, + thumbnailsUrl, loader: widget.loader, ); if (!mounted || serial != _serial) return; @@ -66,11 +76,25 @@ class _PlayerOpeningFrameState extends State { @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, + final posterUrl = widget.posterUrl; + return Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: AppColors.mediaBlack), + if (posterUrl != null) + RemoteImage( + posterUrl, + key: const Key('player-opening-poster'), + fit: BoxFit.cover, + fallbackColor: AppColors.mediaBlack, + ), + if (frame != null) + SpriteCropPreview( + key: const Key('player-opening-sprite'), + image: frame.image, + sourceRect: frame.sourceRect, + ), + ], ); } } diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 1c1dff7..5f9ab14 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -148,6 +148,64 @@ void main() { await session.close(); }); + test('should_play_previous_episode_when_swiping_back', () 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; + await session.select(2, preloadIndex: 3, previousIndex: 1); + await session.preloadSettled; + expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-2')); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + + expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-1')); + expect( + factory.players.firstWhere((player) => player.id == 'ep-1').playing, + isTrue, + ); + expect(session.userWantsPlay, isTrue); + await session.close(); + }); + + test('should_keep_manual_pause_when_returning_to_same_episode', () 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; + session.setUserWantsPlay(false); + await session.playbackSettled; + expect(session.userWantsPlay, isFalse); + + await session.select(2, preloadIndex: 3, previousIndex: 1); + await session.preloadSettled; + expect(session.userWantsPlay, isTrue); + expect( + factory.players.firstWhere((player) => player.id == 'ep-2').playing, + isTrue, + ); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + expect(session.userWantsPlay, isFalse); + expect( + factory.players.firstWhere((player) => player.id == 'ep-1').playing, + isFalse, + ); + await session.close(); + }); + test('should_reuse_in_flight_preload_when_swiped_before_ready', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index d4a2f02..1571fda 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -30,7 +30,7 @@ void main() { scrubPreviewLeft(trackWidth: 300, fraction: 1), 300 - scrubPreviewWidth - scrubPreviewSafeMargin, ); - expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0); + expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 2); }); testWidgets('should_show_inactive_track_when_video_is_not_initialized', ( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 6eb8625..b10bc7c 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -50,7 +50,11 @@ void main() { .homeMode, isTrue, ); - expect(find.byType(PageView), findsOneWidget); + final pageViews = tester.widgetList(find.byType(PageView)); + expect( + pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical), + hasLength(1), + ); expect(tester.takeException(), isNull); }); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index fd166de..b8b3acb 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -16,10 +16,17 @@ 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(null, posterUrl: ' '), isNull); expect(playerOpeningFrame(' '), isNull); }); + test('should_keep_opening_frame_when_only_poster_is_available', () { + expect( + playerOpeningFrame(null, posterUrl: 'https://cdn.test/poster.jpg'), + isA(), + ); + }); + testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async { final loader = _FakeSpriteLoader(); final cache = OpeningFrameCache(); @@ -58,6 +65,24 @@ void main() { expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); }); + + testWidgets('should_show_poster_while_sprite_is_loading', (tester) async { + final cache = OpeningFrameCache(); + addTearDown(cache.clear); + + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame( + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + posterUrl: 'https://cdn.test/poster.jpg', + loader: _FakeSpriteLoader(), + cache: cache, + ), + ), + ); + + expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); + }); } class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { From 04f034817ca46c19a32129425b02f27b613569b1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:01:42 +0800 Subject: [PATCH 04/42] =?UTF-8?q?fix(player):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E8=BF=87=E6=B8=A1=E4=B8=8E=E6=8D=A2=E9=9B=86?= =?UTF-8?q?=E8=B5=B7=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/playback_session.dart | 5 ++-- .../services/playback_session_controls.dart | 10 +------ .../player/widgets/player_video_surface.dart | 7 +++-- test/playback/playback_preload_test.dart | 9 ++++-- test/player/player_opening_frame_test.dart | 28 +++++++++++++++++++ test/player/player_video_surface_test.dart | 25 +++++++++++++++-- 6 files changed, 65 insertions(+), 19 deletions(-) diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index a7266e7..1a603a5 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -41,7 +41,6 @@ class PlaybackSession extends ChangeNotifier { final Future Function()? waitForFrame; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; - final Set _pausedEpisodeIds = {}; _PlayerSlot? _active; _PlayerSlot? _next; _PlayerSlot? _previous; @@ -207,9 +206,9 @@ class PlaybackSession extends ChangeNotifier { int? previousIndex, }) async { if (_closed || index < 0 || index >= episodes.length) return; - // 新集默认起播;用户手动暂停过的同一集滑回时继续尊重暂停。 + // 切换到不同剧集时恢复播放;仍停留在同一集时保留用户暂停。 if (!_selected || index != _currentIndex) { - _userWantsPlay = !_pausedEpisodeIds.contains(episodes[index].id); + _userWantsPlay = true; } final request = ++_generation; _selected = true; diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index dd9175b..b0f12f1 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -37,16 +37,8 @@ extension PlaybackSessionControls on PlaybackSession { _syncPlayback(); } - /// 记录用户播放意图;手动暂停的剧集滑回时不再自动起播。 + /// 记录用户播放意图;切换剧集时会在 [PlaybackSession.select] 中恢复播放。 void setUserWantsPlay(bool value) { - if (episodes.isNotEmpty) { - final episodeId = episodes[_currentIndex].id; - if (value) { - _pausedEpisodeIds.remove(episodeId); - } else { - _pausedEpisodeIds.add(episodeId); - } - } _userWantsPlay = value; _syncPlayback(); _notify(); diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 69890f9..d57551d 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -64,9 +64,12 @@ class _PlayerVideoSurfaceState extends State { key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), - if (!reveal && widget.openingFrame != null) - KeyedSubtree( + if (widget.openingFrame != null) + AnimatedOpacity( key: const Key('player-opening-frame'), + opacity: reveal ? 0 : 1, + duration: handoff != null ? Duration.zero : _videoFadeDuration, + curve: Curves.easeOut, child: widget.openingFrame!, ), AnimatedOpacity( diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 5f9ab14..15bdd23 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -174,7 +174,7 @@ void main() { await session.close(); }); - test('should_keep_manual_pause_when_returning_to_same_episode', () async { + test('should_auto_play_previous_after_switching_from_paused_episode', () async { final factory = _Factory(); final session = PlaybackSession( episodes: episodes, @@ -195,13 +195,16 @@ void main() { factory.players.firstWhere((player) => player.id == 'ep-2').playing, isTrue, ); + session.setUserWantsPlay(false); + await session.playbackSettled; + expect(session.userWantsPlay, isFalse); await session.select(1, preloadIndex: 2, previousIndex: 0); await session.preloadSettled; - expect(session.userWantsPlay, isFalse); + expect(session.userWantsPlay, isTrue); expect( factory.players.firstWhere((player) => player.id == 'ep-1').playing, - isFalse, + isTrue, ); await session.close(); }); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index b8b3acb..e65c4da 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -83,6 +83,34 @@ void main() { expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); }); + + testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', ( + tester, + ) async { + final loader = _FakeSpriteLoader(); + final cache = OpeningFrameCache(); + addTearDown(cache.clear); + Widget build() => MaterialApp( + home: PlayerOpeningFrame( + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + posterUrl: 'https://cdn.test/poster.jpg', + loader: loader, + cache: cache, + ), + ); + + await tester.pumpWidget(build()); + await tester.pumpAndSettle(); + expect(loader.vttCalls, 1); + expect(loader.spriteCalls, hasLength(1)); + + await tester.pumpWidget(build()); + await tester.pumpAndSettle(); + + expect(loader.vttCalls, 1); + expect(loader.spriteCalls, hasLength(1)); + expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); + }); } class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 6ec0315..120e76a 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -30,6 +30,7 @@ void main() { expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); expect(find.byKey(const Key('fake-opening')), findsOneWidget); + expect(_openingOpacity(tester), 1); expect(_opacity(tester), 0); }); @@ -48,7 +49,7 @@ void main() { expect(find.byKey(const Key('player-opening-frame')), findsNothing); }); - testWidgets('should_fade_video_in_within_200ms_after_first_frame', ( + testWidgets('should_cross_fade_video_over_opening_frame', ( tester, ) async { final controller = await _pumpSurface( @@ -67,7 +68,7 @@ void main() { await tester.pump(); expect(_opacity(tester), 1); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); + expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); expect( tester .widget( @@ -80,8 +81,12 @@ void main() { final halfway = _fadeValue(tester); expect(halfway, greaterThan(0)); expect(halfway, lessThan(1)); + final openingHalfway = _openingFadeValue(tester); + expect(openingHalfway, greaterThan(0)); + expect(openingHalfway, lessThan(1)); await tester.pump(const Duration(milliseconds: 100)); expect(_fadeValue(tester), 1); + expect(_openingFadeValue(tester), 0); }); testWidgets('should_show_video_error_above_retained_fallback', ( @@ -182,6 +187,14 @@ double _opacity(WidgetTester tester) { .opacity; } +double _openingOpacity(WidgetTester tester) { + return tester + .widget( + find.byKey(const Key('player-opening-frame')), + ) + .opacity; +} + double _fadeValue(WidgetTester tester) { final fade = find.descendant( of: find.byKey(const Key('player-video-opacity')), @@ -189,3 +202,11 @@ double _fadeValue(WidgetTester tester) { ); return tester.widget(fade).opacity.value; } + +double _openingFadeValue(WidgetTester tester) { + final fade = find.descendant( + of: find.byKey(const Key('player-opening-frame')), + matching: find.byType(FadeTransition), + ); + return tester.widget(fade).opacity.value; +} From a3a79f706828d4330c49e11588900b41e9761bf1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:07:36 +0800 Subject: [PATCH 05/42] =?UTF-8?q?fix(player):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E6=AF=94=E4=BE=8B=E4=B8=8E=E9=A6=96=E5=B8=A7?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 1 + .../player/widgets/player_opening_frame.dart | 35 ++++++++-- .../player/widgets/player_video_surface.dart | 68 ++++++++++++++++++- test/player/player_opening_frame_test.dart | 35 ++++++++++ test/player/player_video_surface_test.dart | 16 +++++ 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7b07f04..7ce9218 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -612,6 +612,7 @@ class _PlayerPageState extends State handoff: handoffPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, + holdOpeningUntilFirstFrame: logic.session.userWantsPlay, openingFrame: playerOpeningFrame( episode.thumbnailsUrl, posterUrl: drama.posterUrl, diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index 594c456..fb7022a 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -12,6 +12,9 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; +const double _posterAspectRatio = 3 / 4; +const double _posterBlurSigma = 18; + /// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) { final url = thumbnailsUrl?.trim() ?? ''; @@ -82,11 +85,33 @@ class _PlayerOpeningFrameState extends State { children: [ const ColoredBox(color: AppColors.mediaBlack), if (posterUrl != null) - RemoteImage( - posterUrl, - key: const Key('player-opening-poster'), - fit: BoxFit.cover, - fallbackColor: AppColors.mediaBlack, + Positioned.fill( + child: ImageFiltered( + key: const Key('player-opening-poster-blur'), + imageFilter: ui.ImageFilter.blur( + sigmaX: _posterBlurSigma, + sigmaY: _posterBlurSigma, + ), + child: RemoteImage( + posterUrl, + key: const Key('player-opening-poster-backdrop'), + fit: BoxFit.cover, + fallbackColor: AppColors.mediaBlack, + ), + ), + ), + if (posterUrl != null) + Center( + child: AspectRatio( + key: const Key('player-opening-poster-stage'), + aspectRatio: _posterAspectRatio, + child: RemoteImage( + posterUrl, + key: const Key('player-opening-poster'), + fit: BoxFit.cover, + fallbackColor: AppColors.mediaBlack, + ), + ), ), if (frame != null) SpriteCropPreview( diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index d57551d..a1a3618 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -19,6 +19,7 @@ class PlayerVideoSurface extends StatefulWidget { required this.theater, this.handoff, this.openingFrame, + this.holdOpeningUntilFirstFrame = false, }); final VideoPlayerController? controller; @@ -26,6 +27,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 解码出第一帧之前垫上的视频首帧,没有则为空。 final Widget? openingFrame; + + /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 + final bool holdOpeningUntilFirstFrame; final String videoUrl; final bool theater; @@ -35,11 +39,21 @@ class PlayerVideoSurface extends StatefulWidget { class _PlayerVideoSurfaceState extends State { String? _lastDebugState; + String? _lastLayerState; + VideoPlayerController? _firstFrameController; + bool _playbackStarted = false; + bool _firstFrameSeen = false; @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) _lastDebugState = null; + if (oldWidget.controller != widget.controller) { + _lastDebugState = null; + _lastLayerState = null; + _firstFrameController = null; + _playbackStarted = false; + _firstFrameSeen = false; + } } @override @@ -55,8 +69,15 @@ class _PlayerVideoSurfaceState extends State { Widget _frame(VideoPlayerValue? value) { final controller = widget.controller; final handoff = _liveHandoff(controller); - final reveal = _shouldReveal(value, handoff != null); + final firstFrameReady = _trackFrameState(controller, value); + final reveal = _shouldReveal(value, handoff != null) && + (!widget.holdOpeningUntilFirstFrame || + firstFrameReady || + _playbackStarted && + value?.isPlaying == false && + value?.isBuffering != true); _logStateChange(value); + _logLayerState(value, handoff, reveal, firstFrameReady); return Stack( fit: StackFit.expand, children: [ @@ -116,6 +137,22 @@ class _PlayerVideoSurfaceState extends State { return value.size.width > 0 && value.size.height > 0; } + bool _trackFrameState( + VideoPlayerController? controller, + VideoPlayerValue? value, + ) { + if (!identical(_firstFrameController, controller)) { + _firstFrameController = controller; + _playbackStarted = false; + _firstFrameSeen = false; + } + if (value == null) return false; + _playbackStarted = _playbackStarted || value.isPlaying; + _firstFrameSeen = + _firstFrameSeen || value.position > Duration.zero || value.isCompleted; + return _firstFrameSeen; + } + /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { final handoff = widget.handoff; @@ -138,6 +175,33 @@ class _PlayerVideoSurfaceState extends State { ); } + void _logLayerState( + VideoPlayerValue? value, + VideoPlayerController? handoff, + bool reveal, + bool firstFrameReady, + ) { + if (!kDebugMode) return; + final reason = handoff != null + ? 'handoff' + : !_shouldReveal(value, handoff != null) + ? 'uninitialized' + : !firstFrameReady && widget.holdOpeningUntilFirstFrame + ? 'awaiting-first-frame' + : 'reveal'; + final signature = + 'opening=${widget.openingFrame == null ? 0 : reveal ? 0 : 1} ' + 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' + 'init=${value?.isInitialized == true ? 1 : 0} ' + 'frame=${firstFrameReady ? 1 : 0} ' + 'playing=${value?.isPlaying == true ? 1 : 0} ' + 'buffering=${value?.isBuffering == true ? 1 : 0} ' + 'reason=$reason'; + if (signature == _lastLayerState) return; + _lastLayerState = signature; + debugPrint('[player-layer] $signature'); + } + String _debugStatus(VideoPlayerValue? value) { if (value == null) return 'loading'; if (value.hasError) { diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index e65c4da..bc6d1d4 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -5,6 +5,7 @@ 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/core/widgets/remote_image.dart'; import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart'; const String _spriteVtt = ''' @@ -82,6 +83,40 @@ void main() { ); expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); + expect( + find.byKey(const Key('player-opening-poster-backdrop')), + findsOneWidget, + ); + }); + + testWidgets('should_constrain_poster_inside_native_vertical_frame', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: PlayerOpeningFrame( + posterUrl: 'https://cdn.test/poster.jpg', + ), + ), + ); + + final stage = tester.widget( + find.byKey(const Key('player-opening-poster-stage')), + ); + final poster = tester.widget( + find.byKey(const Key('player-opening-poster')), + ); + final backdrop = tester.widget( + find.byKey(const Key('player-opening-poster-backdrop')), + ); + + expect(stage.aspectRatio, closeTo(3 / 4, 0.001)); + expect(poster.fit, BoxFit.cover); + expect(backdrop.fit, BoxFit.cover); + expect( + find.byKey(const Key('player-opening-poster-blur')), + findsOneWidget, + ); }); testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', ( diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 120e76a..5489223 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -55,9 +55,23 @@ void main() { final controller = await _pumpSurface( tester, openingFrame: const ColoredBox(color: Colors.red), + holdOpeningUntilFirstFrame: true, ); expect(_opacity(tester), 0); + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration.zero, + isInitialized: true, + isPlaying: true, + ); + await tester.pump(); + + expect(_opacity(tester), 0); + expect(_openingOpacity(tester), 1); + expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); + controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), @@ -153,6 +167,7 @@ Future _pumpSurface( WidgetTester tester, { VideoPlayerValue? value, Widget? openingFrame, + bool holdOpeningUntilFirstFrame = false, }) async { final controller = VideoPlayerController.networkUrl( Uri.parse('http://192.168.31.187:8081/files/video.mp4'), @@ -167,6 +182,7 @@ Future _pumpSurface( videoUrl: controller.dataSource, theater: false, openingFrame: openingFrame, + holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame, ), ), ), From 48b4e67d3497d86b70f440ffbd2e125bb155ed81 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:12:42 +0800 Subject: [PATCH 06/42] =?UTF-8?q?fix(player):=20=E9=A2=84=E7=83=AD?= =?UTF-8?q?=E7=9B=B4=E5=87=BA=E5=B9=B6=E4=B8=8E=E8=A7=86=E9=A2=91=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=B0=81=E9=9D=A2=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/playback_session.dart | 2 + .../services/playback_session_controls.dart | 10 +++ lib/pages/player/player_view.dart | 5 ++ .../player/widgets/player_opening_frame.dart | 45 ++++--------- .../player/widgets/player_video_surface.dart | 41 ++++++++---- .../player/widgets/scrub_preview_card.dart | 21 ++++-- test/playback/playback_preload_test.dart | 2 + test/player/player_opening_frame_test.dart | 30 ++++----- test/player/player_video_surface_test.dart | 67 ++++++++----------- 9 files changed, 119 insertions(+), 104 deletions(-) diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 1a603a5..e3168fd 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -260,6 +260,7 @@ class PlaybackSession extends ChangeNotifier { _usableWarm(this, warmNext, index) ?? _usableWarm(this, warmPrevious, index); if (warm != null) { + warm.reusedWarm = true; target = _active = warm; _loading = !warm.prepared; _notify(); @@ -470,5 +471,6 @@ class _PlayerSlot { bool activated = false; bool playing = false; bool retiring = false; + bool reusedWarm = false; Object? failure; } diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index b0f12f1..e0a4045 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -19,6 +19,16 @@ extension PlaybackSessionControls on PlaybackSession { return slot.player; } + /// 当前索引是否复用了已预热的播放器槽。 + bool reusedWarmAt(int index) { + for (final slot in [_active, _next, _previous]) { + if (slot != null && !slot.retiring && slot.index == index) { + return slot.reusedWarm; + } + } + return false; + } + /// 同步应用前台状态并恢复或暂停当前集。 void setAppResumed(bool value) { _appResumed = value; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7ce9218..fcc6ddb 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -587,6 +587,9 @@ class _PlayerPageState extends State if (selected && videoReady) _logSwipeReveal(); final ready = videoReady || port != null; final theater = logic.pip || logic.pipPreparing; + final videoFit = theater ? BoxFit.contain : BoxFit.cover; + final preparedFrameAvailable = + selected && logic.session.reusedWarmAt(index); final locked = logic.session.isLocked || episode.locked; if (selected && videoPort != null && @@ -613,9 +616,11 @@ class _PlayerPageState extends State videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, holdOpeningUntilFirstFrame: logic.session.userWantsPlay, + preparedFrameAvailable: preparedFrameAvailable, openingFrame: playerOpeningFrame( episode.thumbnailsUrl, posterUrl: drama.posterUrl, + fit: videoFit, ), ), if (selected && diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index fb7022a..4596643 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -12,17 +12,19 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; -const double _posterAspectRatio = 3 / 4; -const double _posterBlurSigma = 18; - /// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 -Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) { +Widget? playerOpeningFrame( + String? thumbnailsUrl, { + String? posterUrl, + BoxFit fit = BoxFit.cover, +}) { final url = thumbnailsUrl?.trim() ?? ''; final poster = posterUrl?.trim() ?? ''; if (url.isEmpty && poster.isEmpty) return null; return PlayerOpeningFrame( thumbnailsUrl: url.isEmpty ? null : url, posterUrl: poster.isEmpty ? null : poster, + fit: fit, ); } @@ -32,12 +34,14 @@ class PlayerOpeningFrame extends StatefulWidget { super.key, this.thumbnailsUrl, this.posterUrl, + this.fit = BoxFit.cover, this.loader, this.cache, }); final String? thumbnailsUrl; final String? posterUrl; + final BoxFit fit; final SpriteThumbnailAssetLoader? loader; final OpeningFrameCache? cache; @@ -85,39 +89,18 @@ class _PlayerOpeningFrameState extends State { children: [ const ColoredBox(color: AppColors.mediaBlack), if (posterUrl != null) - Positioned.fill( - child: ImageFiltered( - key: const Key('player-opening-poster-blur'), - imageFilter: ui.ImageFilter.blur( - sigmaX: _posterBlurSigma, - sigmaY: _posterBlurSigma, - ), - child: RemoteImage( - posterUrl, - key: const Key('player-opening-poster-backdrop'), - fit: BoxFit.cover, - fallbackColor: AppColors.mediaBlack, - ), - ), - ), - if (posterUrl != null) - Center( - child: AspectRatio( - key: const Key('player-opening-poster-stage'), - aspectRatio: _posterAspectRatio, - child: RemoteImage( - posterUrl, - key: const Key('player-opening-poster'), - fit: BoxFit.cover, - fallbackColor: AppColors.mediaBlack, - ), - ), + RemoteImage( + posterUrl, + key: const Key('player-opening-poster'), + fit: widget.fit, + fallbackColor: AppColors.mediaBlack, ), if (frame != null) SpriteCropPreview( key: const Key('player-opening-sprite'), image: frame.image, sourceRect: frame.sourceRect, + fit: widget.fit, ), ], ); diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index a1a3618..eb23e61 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -6,7 +6,6 @@ import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; -const Duration _videoFadeDuration = Duration(milliseconds: 200); const double _errorHorizontal = 32; const int _errorMaxLines = 3; @@ -20,6 +19,7 @@ class PlayerVideoSurface extends StatefulWidget { this.handoff, this.openingFrame, this.holdOpeningUntilFirstFrame = false, + this.preparedFrameAvailable = false, }); final VideoPlayerController? controller; @@ -30,6 +30,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 final bool holdOpeningUntilFirstFrame; + + /// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。 + final bool preparedFrameAvailable; final String videoUrl; final bool theater; @@ -69,15 +72,27 @@ class _PlayerVideoSurfaceState extends State { Widget _frame(VideoPlayerValue? value) { final controller = widget.controller; final handoff = _liveHandoff(controller); - final firstFrameReady = _trackFrameState(controller, value); - final reveal = _shouldReveal(value, handoff != null) && + final firstFrameSeen = _trackFrameState(controller, value); + final firstFrameReady = + widget.preparedFrameAvailable || firstFrameSeen; + final textureReady = _shouldReveal(value, handoff != null); + final reveal = textureReady && (!widget.holdOpeningUntilFirstFrame || + widget.preparedFrameAvailable || firstFrameReady || _playbackStarted && value?.isPlaying == false && value?.isBuffering != true); + final showOpening = + widget.openingFrame != null && handoff == null && !reveal; _logStateChange(value); - _logLayerState(value, handoff, reveal, firstFrameReady); + _logLayerState( + value, + handoff, + reveal, + firstFrameReady, + showOpening, + ); return Stack( fit: StackFit.expand, children: [ @@ -85,19 +100,15 @@ class _PlayerVideoSurfaceState extends State { key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), - if (widget.openingFrame != null) - AnimatedOpacity( + if (showOpening) + KeyedSubtree( key: const Key('player-opening-frame'), - opacity: reveal ? 0 : 1, - duration: handoff != null ? Duration.zero : _videoFadeDuration, - curve: Curves.easeOut, child: widget.openingFrame!, ), AnimatedOpacity( key: const Key('player-video-opacity'), opacity: reveal ? 1 : 0, - duration: handoff != null ? Duration.zero : _videoFadeDuration, - curve: Curves.easeOut, + duration: Duration.zero, child: reveal && controller != null ? _VideoLayer( controller: controller, @@ -180,20 +191,24 @@ class _PlayerVideoSurfaceState extends State { VideoPlayerController? handoff, bool reveal, bool firstFrameReady, + bool showOpening, ) { if (!kDebugMode) return; final reason = handoff != null ? 'handoff' : !_shouldReveal(value, handoff != null) ? 'uninitialized' + : widget.preparedFrameAvailable + ? 'warm-frame' : !firstFrameReady && widget.holdOpeningUntilFirstFrame ? 'awaiting-first-frame' : 'reveal'; final signature = - 'opening=${widget.openingFrame == null ? 0 : reveal ? 0 : 1} ' + 'warm=${widget.preparedFrameAvailable ? 1 : 0} ' + 'frame=${firstFrameReady ? 1 : 0} ' + 'cover=${showOpening ? 1 : 0} ' 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'init=${value?.isInitialized == true ? 1 : 0} ' - 'frame=${firstFrameReady ? 1 : 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' 'buffering=${value?.isBuffering == true ? 1 : 0} ' 'reason=$reason'; diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index 266d2db..65eb2dc 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -256,26 +256,37 @@ class SpriteCropPreview extends StatelessWidget { super.key, required this.image, required this.sourceRect, + this.fit = BoxFit.cover, }); final ui.Image image; final ui.Rect sourceRect; + final BoxFit fit; @override Widget build(BuildContext context) => CustomPaint( - painter: _SpriteCropPainter(image: image, sourceRect: sourceRect), + painter: _SpriteCropPainter( + image: image, + sourceRect: sourceRect, + fit: fit, + ), ); } class _SpriteCropPainter extends CustomPainter { - const _SpriteCropPainter({required this.image, required this.sourceRect}); + const _SpriteCropPainter({ + required this.image, + required this.sourceRect, + required this.fit, + }); final ui.Image image; final ui.Rect sourceRect; + final BoxFit fit; @override void paint(Canvas canvas, Size size) { - final fitted = applyBoxFit(BoxFit.cover, sourceRect.size, size); + final fitted = applyBoxFit(fit, sourceRect.size, size); final sourceOffset = Alignment.center.inscribe( fitted.source, Offset.zero & sourceRect.size, @@ -302,7 +313,9 @@ class _SpriteCropPainter extends CustomPainter { @override bool shouldRepaint(covariant _SpriteCropPainter oldDelegate) => - oldDelegate.image != image || oldDelegate.sourceRect != sourceRect; + oldDelegate.image != image || + oldDelegate.sourceRect != sourceRect || + oldDelegate.fit != fit; } String _format(Duration value) { diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 15bdd23..bf0dbdc 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -161,11 +161,13 @@ void main() { await session.select(2, preloadIndex: 3, previousIndex: 1); await session.preloadSettled; expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-2')); + expect(session.reusedWarmAt(2), isTrue); await session.select(1, preloadIndex: 2, previousIndex: 0); await session.preloadSettled; expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-1')); + expect(session.reusedWarmAt(1), isTrue); expect( factory.players.firstWhere((player) => player.id == 'ep-1').playing, isTrue, diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index bc6d1d4..139e9fe 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -83,13 +83,9 @@ void main() { ); expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); - expect( - find.byKey(const Key('player-opening-poster-backdrop')), - findsOneWidget, - ); }); - testWidgets('should_constrain_poster_inside_native_vertical_frame', ( + testWidgets('should_use_surface_fit_without_extra_frame_constraint', ( tester, ) async { await tester.pumpWidget( @@ -100,23 +96,23 @@ void main() { ), ); - final stage = tester.widget( - find.byKey(const Key('player-opening-poster-stage')), - ); final poster = tester.widget( find.byKey(const Key('player-opening-poster')), ); - final backdrop = tester.widget( - find.byKey(const Key('player-opening-poster-backdrop')), + + expect(poster.fit, BoxFit.cover); + expect(find.byType(AspectRatio), findsNothing); + }); + + test('should_forward_video_fit_to_opening_frame', () { + final frame = playerOpeningFrame( + null, + posterUrl: 'https://cdn.test/poster.jpg', + fit: BoxFit.contain, ); - expect(stage.aspectRatio, closeTo(3 / 4, 0.001)); - expect(poster.fit, BoxFit.cover); - expect(backdrop.fit, BoxFit.cover); - expect( - find.byKey(const Key('player-opening-poster-blur')), - findsOneWidget, - ); + expect(frame, isA()); + expect((frame! as PlayerOpeningFrame).fit, BoxFit.contain); }); testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', ( diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 5489223..9f70bee 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -30,7 +30,6 @@ void main() { expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); expect(find.byKey(const Key('fake-opening')), findsOneWidget); - expect(_openingOpacity(tester), 1); expect(_opacity(tester), 0); }); @@ -49,7 +48,7 @@ void main() { expect(find.byKey(const Key('player-opening-frame')), findsNothing); }); - testWidgets('should_cross_fade_video_over_opening_frame', ( + testWidgets('should_show_cover_until_first_playback_frame', ( tester, ) async { final controller = await _pumpSurface( @@ -69,7 +68,6 @@ void main() { await tester.pump(); expect(_opacity(tester), 0); - expect(_openingOpacity(tester), 1); expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); controller.value = const VideoPlayerValue( @@ -82,25 +80,36 @@ void main() { await tester.pump(); expect(_opacity(tester), 1); - expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); + expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect( tester .widget( find.byKey(const Key('player-video-opacity')), ) .duration, - const Duration(milliseconds: 200), + Duration.zero, ); - await tester.pump(const Duration(milliseconds: 100)); - final halfway = _fadeValue(tester); - expect(halfway, greaterThan(0)); - expect(halfway, lessThan(1)); - final openingHalfway = _openingFadeValue(tester); - expect(openingHalfway, greaterThan(0)); - expect(openingHalfway, lessThan(1)); - await tester.pump(const Duration(milliseconds: 100)); - expect(_fadeValue(tester), 1); - expect(_openingFadeValue(tester), 0); + }); + + testWidgets('should_skip_cover_when_prewarmed_frame_is_available', ( + tester, + ) async { + await _pumpSurface( + tester, + openingFrame: const ColoredBox(color: Colors.red), + holdOpeningUntilFirstFrame: true, + preparedFrameAvailable: true, + value: const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration.zero, + isInitialized: true, + isPlaying: false, + ), + ); + + expect(find.byKey(const Key('player-opening-frame')), findsNothing); + expect(_opacity(tester), 1); }); testWidgets('should_show_video_error_above_retained_fallback', ( @@ -147,6 +156,7 @@ void main() { handoff: handoff, videoUrl: current.dataSource, theater: false, + openingFrame: const ColoredBox(color: Colors.red), ), ), ), @@ -155,6 +165,7 @@ void main() { await pump(previous); expect(find.byKey(const Key('player-video-handoff')), findsOneWidget); + expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect(_opacity(tester), 1); await pump(null); @@ -168,6 +179,7 @@ Future _pumpSurface( VideoPlayerValue? value, Widget? openingFrame, bool holdOpeningUntilFirstFrame = false, + bool preparedFrameAvailable = false, }) async { final controller = VideoPlayerController.networkUrl( Uri.parse('http://192.168.31.187:8081/files/video.mp4'), @@ -183,6 +195,7 @@ Future _pumpSurface( theater: false, openingFrame: openingFrame, holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame, + preparedFrameAvailable: preparedFrameAvailable, ), ), ), @@ -202,27 +215,3 @@ double _opacity(WidgetTester tester) { .widget(find.byKey(const Key('player-video-opacity'))) .opacity; } - -double _openingOpacity(WidgetTester tester) { - return tester - .widget( - find.byKey(const Key('player-opening-frame')), - ) - .opacity; -} - -double _fadeValue(WidgetTester tester) { - final fade = find.descendant( - of: find.byKey(const Key('player-video-opacity')), - matching: find.byType(FadeTransition), - ); - return tester.widget(fade).opacity.value; -} - -double _openingFadeValue(WidgetTester tester) { - final fade = find.descendant( - of: find.byKey(const Key('player-opening-frame')), - matching: find.byType(FadeTransition), - ); - return tester.widget(fade).opacity.value; -} From 70d8e0f982621b25691b71551db7bd0bf72f165b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:21:52 +0800 Subject: [PATCH 07/42] =?UTF-8?q?fix(player):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=9B=AA=E7=A2=A7=E5=9B=BE=E9=A2=84=E7=83=AD=E9=A6=96=E5=B8=A7?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/sprite_thumbnail_axis.dart | 53 ++--- lib/pages/player/player_view.dart | 47 +++- .../player/widgets/home_feed_overlay.dart | 1 - .../widgets/home_feed_overlay_parts.dart | 3 - .../player/widgets/player_opening_frame.dart | 200 +++++------------- lib/pages/player/widgets/player_overlay.dart | 1 - .../player/widgets/player_video_surface.dart | 30 ++- .../player/widgets/scrub_preview_card.dart | 17 +- .../player/widgets/seek_progress_bar.dart | 3 - test/playback/seek_progress_bar_test.dart | 7 +- test/playback/sprite_thumbnail_axis_test.dart | 26 +++ test/player/player_opening_frame_test.dart | 132 ++++-------- test/player/scrub_preview_card_test.dart | 9 +- 13 files changed, 207 insertions(+), 322 deletions(-) diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart index 55f10da..e6f7080 100644 --- a/lib/core/data/services/sprite_thumbnail_axis.dart +++ b/lib/core/data/services/sprite_thumbnail_axis.dart @@ -132,36 +132,37 @@ class SpriteThumbnailAxisService extends ChangeNotifier SpriteThumbnailAxisService({ SpriteThumbnailAssetLoader? loader, this.cacheTtl = spriteVttCacheTtl, + this.spriteCacheCapacity = spriteImageCacheCapacity, DateTime Function()? now, }) : _loader = loader ?? DioSpriteThumbnailAssetLoader(), _now = now ?? DateTime.now; final SpriteThumbnailAssetLoader _loader; final Duration cacheTtl; + final int spriteCacheCapacity; final DateTime Function() _now; final Map _axisCache = {}; final Map> _axisRequests = {}; final LinkedHashMap _spriteCache = LinkedHashMap(); final Map> _spriteRequests = {}; final Set _failedSprites = {}; - String? _activeAxisUrl; - int _axisGeneration = 0; bool _disposed = false; @visibleForTesting int get spriteCacheSize => _spriteCache.length; @visibleForTesting - List get spriteCacheKeys => _spriteCache.keys.toList(); + List get spriteCacheKeys => _spriteCache.keys + .map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url) + .toList(); /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 @override Future prepareAt(String? thumbnailsUrl, Duration position) async { final url = _normalizeUrl(thumbnailsUrl); if (_disposed || url == null) return; - _activateAxis(url); final axis = await _loadAxis(url); - if (_disposed || _activeAxisUrl != url) return; + if (_disposed) return; for (final cue in _prefetchCues(axis, position)) { await _ensureSprite(axis, cue); } @@ -170,7 +171,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier @override bool hasAxis(String? thumbnailsUrl) { final url = _normalizeUrl(thumbnailsUrl); - if (url == null || url != _activeAxisUrl) return false; + if (url == null) return false; return _readAxis(url) != null; } @@ -180,12 +181,14 @@ class SpriteThumbnailAxisService extends ChangeNotifier Duration position, ) { final url = _normalizeUrl(thumbnailsUrl); - if (url == null || url != _activeAxisUrl) return null; + if (url == null) return null; final axis = _readAxis(url); if (axis == null) return null; final cue = selectSpriteThumbnailCue(axis.cues, position); if (cue == null) return null; - final cached = _readSprite(cue.spriteFile); + final cached = _readSprite( + axis.resolveSpriteUri(cue.spriteFile).toString(), + ); if (cached == null) return null; final sourceRect = _scaledSourceRect(cached, cue.sourceRect); if (sourceRect == null) return null; @@ -206,13 +209,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier super.dispose(); } - void _activateAxis(String url) { - if (_activeAxisUrl == url) return; - _activeAxisUrl = url; - _axisGeneration++; - _clearSprites(); - } - Future _loadAxis(String url) { final cached = _readAxis(url); if (cached != null) return Future.value(cached); @@ -239,7 +235,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier } on SpriteThumbnailForbiddenException { if (_disposed) return SpriteThumbnailAxis.empty(url); _axisCache.remove(url); - if (_activeAxisUrl == url) _clearSprites(); } catch (_) { break; } @@ -293,23 +288,23 @@ class SpriteThumbnailAxisService extends ChangeNotifier SpriteThumbnailAxis axis, SpriteThumbnailCue cue, ) { - final cached = _readSprite(cue.spriteFile); + final spriteUrl = axis.resolveSpriteUri(cue.spriteFile).toString(); + final cached = _readSprite(spriteUrl); if (cached != null) return Future.value(cached); - if (_failedSprites.contains(cue.spriteFile)) return Future.value(); - final existing = _spriteRequests[cue.spriteFile]; + if (_failedSprites.contains(spriteUrl)) return Future.value(); + final existing = _spriteRequests[spriteUrl]; if (existing != null) return existing; - final generation = _axisGeneration; - final request = _loadSprite(axis, cue, generation); - _spriteRequests[cue.spriteFile] = request; + final request = _loadSprite(axis, cue, spriteUrl); + _spriteRequests[spriteUrl] = request; return request.whenComplete(() { - _spriteRequests.remove(cue.spriteFile); + _spriteRequests.remove(spriteUrl); }); } Future<_CachedSprite?> _loadSprite( SpriteThumbnailAxis axis, SpriteThumbnailCue cue, - int generation, + String spriteUrl, ) async { SpriteThumbnailDecodedImage? decoded; try { @@ -318,16 +313,16 @@ class SpriteThumbnailAxisService extends ChangeNotifier decoded = null; } if (decoded == null) { - _failedSprites.add(cue.spriteFile); + _failedSprites.add(spriteUrl); if (!_disposed) notifyListeners(); return null; } - if (_disposed || generation != _axisGeneration) { + if (_disposed) { decoded.image.dispose(); return null; } final cached = _CachedSprite(decoded); - _putSprite(cue.spriteFile, cached); + _putSprite(spriteUrl, cached); notifyListeners(); return cached; } @@ -342,7 +337,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier void _putSprite(String key, _CachedSprite value) { _spriteCache.remove(key)?.decoded.image.dispose(); _spriteCache[key] = value; - while (_spriteCache.length > spriteImageCacheCapacity) { + while (_spriteCache.length > spriteCacheCapacity) { final oldest = _spriteCache.remove(_spriteCache.keys.first); oldest?.decoded.image.dispose(); } @@ -378,8 +373,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier } void _clearAll() { - _axisGeneration++; - _activeAxisUrl = null; _axisCache.clear(); _axisRequests.clear(); _clearSprites(); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index fcc6ddb..90958a2 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -146,7 +146,9 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); - _spriteAxisService = SpriteThumbnailAxisService(); + _spriteAxisService = SpriteThumbnailAxisService( + spriteCacheCapacity: 8, + )..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -266,7 +268,9 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); - _spriteAxisService.dispose(); + _spriteAxisService + ..removeListener(_handleSpriteFrameChanged) + ..dispose(); _thumbnailService.dispose(); logic.dispose(); super.dispose(); @@ -369,6 +373,30 @@ class _PlayerPageState extends State _chromeVisible = true; } + void _handleSpriteFrameChanged() { + if (mounted) setState(() {}); + } + + void _prewarmAdjacentThumbnails() { + final current = logic.session.currentIndex; + final indices = [ + current, + logic.previousVisibleIndex(current), + logic.nextVisibleIndex(current), + ]; + for (final index in indices) { + if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { + continue; + } + final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl; + if (thumbnailsUrl != null) { + unawaited( + _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), + ); + } + } + } + void _restoreFeedChrome() { if (!mounted) return; setState(() { @@ -451,6 +479,7 @@ class _PlayerPageState extends State final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { + _prewarmAdjacentThumbnails(); final pages = NotificationListener( onNotification: _handlePageScroll, child: PageView.builder( @@ -590,6 +619,15 @@ class _PlayerPageState extends State final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = selected && logic.session.reusedWarmAt(index); + final thumbnailsUrl = episode.thumbnailsUrl; + final openingFrameReady = + thumbnailsUrl != null && + _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; + if (thumbnailsUrl != null) { + unawaited( + _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), + ); + } final locked = logic.session.isLocked || episode.locked; if (selected && videoPort != null && @@ -617,9 +655,10 @@ class _PlayerPageState extends State theater: theater, holdOpeningUntilFirstFrame: logic.session.userWantsPlay, preparedFrameAvailable: preparedFrameAvailable, + openingFrameReady: openingFrameReady, openingFrame: playerOpeningFrame( - episode.thumbnailsUrl, - posterUrl: drama.posterUrl, + thumbnailsUrl, + spriteSource: _spriteAxisService, fit: videoFit, ), ), diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 338004d..467cb53 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -217,7 +217,6 @@ class _HomeFeedOverlayState extends State { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.episode.thumbnailsUrl, episodeId: widget.episode.id, - posterUrl: widget.drama.posterUrl, onScrubStart: _startScrub, onScrubEnd: _endScrub, onSeek: widget.onSeek, diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index b6d42f3..8934d39 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -23,7 +23,6 @@ class _FeedProgressBar extends StatefulWidget { this.spriteAxisService, this.thumbnailsUrl, this.episodeId, - this.posterUrl = '', this.onScrubStart, this.onScrubEnd, this.onSeek, @@ -38,7 +37,6 @@ class _FeedProgressBar extends StatefulWidget { final SpriteThumbnailAxisSource? spriteAxisService; final String? thumbnailsUrl; final String? episodeId; - final String posterUrl; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; final ValueChanged? onSeek; @@ -166,7 +164,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, - posterUrl: widget.posterUrl, position: previewPosition, duration: video!.duration, ), diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index 4596643..529f7a4 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -1,199 +1,97 @@ // 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 '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; -/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 +/// 当前集雪碧图首帧在视频纹理未就绪时占位;空地址交还纯黑底。 Widget? playerOpeningFrame( String? thumbnailsUrl, { - String? posterUrl, + required SpriteThumbnailAxisSource spriteSource, BoxFit fit = BoxFit.cover, }) { final url = thumbnailsUrl?.trim() ?? ''; - final poster = posterUrl?.trim() ?? ''; - if (url.isEmpty && poster.isEmpty) return null; + if (url.isEmpty) return null; return PlayerOpeningFrame( - thumbnailsUrl: url.isEmpty ? null : url, - posterUrl: poster.isEmpty ? null : poster, + thumbnailsUrl: url, + spriteSource: spriteSource, fit: fit, ); } -/// 视频解码前依次使用封面和雪碧图首帧,最终由父层黑色兜底。 +/// 与视频共用全屏父约束和 BoxFit,切换时保持相同可见区域。 class PlayerOpeningFrame extends StatefulWidget { const PlayerOpeningFrame({ super.key, - this.thumbnailsUrl, - this.posterUrl, + required this.thumbnailsUrl, + required this.spriteSource, this.fit = BoxFit.cover, - this.loader, - this.cache, }); - final String? thumbnailsUrl; - final String? posterUrl; + /// 当前视频雪碧图 WebVTT 地址。 + final String thumbnailsUrl; + + /// 进度条拖动预览共用的雪碧图时间轴与解码缓存。 + final SpriteThumbnailAxisSource spriteSource; + + /// 必须与同页视频层使用相同适配规则。 final BoxFit fit; - 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()); + widget.spriteSource.addListener(_handleSpriteChanged); + unawaited( + widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), + ); } @override void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.spriteSource != widget.spriteSource) { + oldWidget.spriteSource.removeListener(_handleSpriteChanged); + widget.spriteSource.addListener(_handleSpriteChanged); + } if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { - _frame = null; - unawaited(_load()); + unawaited( + widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), + ); } - } - - Future _load() async { - final serial = ++_serial; - final thumbnailsUrl = widget.thumbnailsUrl; - if (thumbnailsUrl == null) return; - final frame = await (widget.cache ?? OpeningFrameCache.shared).load( - thumbnailsUrl, - loader: widget.loader, - ); - if (!mounted || serial != _serial) return; - setState(() => _frame = frame); } @override - Widget build(BuildContext context) { - final frame = _frame; - final posterUrl = widget.posterUrl; - return Stack( - fit: StackFit.expand, - children: [ - const ColoredBox(color: AppColors.mediaBlack), - if (posterUrl != null) - RemoteImage( - posterUrl, - key: const Key('player-opening-poster'), - fit: widget.fit, - fallbackColor: AppColors.mediaBlack, - ), - if (frame != null) - SpriteCropPreview( - key: const Key('player-opening-sprite'), - image: frame.image, - sourceRect: frame.sourceRect, - fit: widget.fit, - ), - ], - ); - } -} - -/// 相邻页共用的雪碧图首帧缓存,避免每页重复拉 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(); + void dispose() { + widget.spriteSource.removeListener(_handleSpriteChanged); + super.dispose(); } + + void _handleSpriteChanged() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: AppColors.mediaBlack), + if (widget.spriteSource.cachedFrameAt(widget.thumbnailsUrl, Duration.zero) + case final frame?) + SpriteCropPreview( + key: const Key('player-opening-sprite'), + image: frame.image, + sourceRect: frame.sourceRect, + fit: widget.fit, + ), + ], + ); } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index c9612b9..0a89365 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -303,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { spriteAxisService: data.spriteAxisService, thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, - posterUrl: data.drama.posterUrl, inactiveTrackColor: AppColors.inactiveTrack, onScrubStart: actions.onScrubStart, onScrubEnd: actions.onScrubEnd, diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index eb23e61..9392121 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -18,6 +18,7 @@ class PlayerVideoSurface extends StatefulWidget { required this.theater, this.handoff, this.openingFrame, + this.openingFrameReady = false, this.holdOpeningUntilFirstFrame = false, this.preparedFrameAvailable = false, }); @@ -28,6 +29,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 解码出第一帧之前垫上的视频首帧,没有则为空。 final Widget? openingFrame; + /// 当前集雪碧图 t=0 是否已在共享缓存中可用。 + final bool openingFrameReady; + /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 final bool holdOpeningUntilFirstFrame; @@ -73,10 +77,10 @@ class _PlayerVideoSurfaceState extends State { final controller = widget.controller; final handoff = _liveHandoff(controller); final firstFrameSeen = _trackFrameState(controller, value); - final firstFrameReady = - widget.preparedFrameAvailable || firstFrameSeen; + final firstFrameReady = widget.preparedFrameAvailable || firstFrameSeen; final textureReady = _shouldReveal(value, handoff != null); - final reveal = textureReady && + final reveal = + textureReady && (!widget.holdOpeningUntilFirstFrame || widget.preparedFrameAvailable || firstFrameReady || @@ -86,13 +90,7 @@ class _PlayerVideoSurfaceState extends State { final showOpening = widget.openingFrame != null && handoff == null && !reveal; _logStateChange(value); - _logLayerState( - value, - handoff, - reveal, - firstFrameReady, - showOpening, - ); + _logLayerState(value, handoff, reveal, firstFrameReady, showOpening); return Stack( fit: StackFit.expand, children: [ @@ -203,10 +201,20 @@ class _PlayerVideoSurfaceState extends State { : !firstFrameReady && widget.holdOpeningUntilFirstFrame ? 'awaiting-first-frame' : 'reveal'; + final fallback = handoff != null + ? 'handoff' + : showOpening + ? widget.openingFrameReady + ? 'thumbnails-first-frame' + : 'black' + : reveal + ? 'video' + : 'black'; final signature = 'warm=${widget.preparedFrameAvailable ? 1 : 0} ' 'frame=${firstFrameReady ? 1 : 0} ' - 'cover=${showOpening ? 1 : 0} ' + 'thumbnails=${widget.openingFrameReady ? 'ready' : 'pending'} ' + 'fallback=$fallback ' 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'init=${value?.isInitialized == true ? 1 : 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index 65eb2dc..b77a696 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/remote_image.dart'; const double scrubPreviewWidth = 96; const double scrubPreviewSafeMargin = 12; @@ -36,7 +35,6 @@ class ScrubPreviewCard extends StatefulWidget { super.key, required this.service, required this.episodeId, - required this.posterUrl, required this.position, required this.duration, this.spriteAxisService, @@ -47,7 +45,6 @@ class ScrubPreviewCard extends StatefulWidget { final SpriteThumbnailAxisSource? spriteAxisService; final String? thumbnailsUrl; final String episodeId; - final String posterUrl; final Duration position; final Duration duration; @@ -207,11 +204,9 @@ class _ScrubPreviewCardState extends State { child: Stack( fit: StackFit.expand, children: [ - RemoteImage( - widget.posterUrl, - key: const Key('scrub-preview-poster'), - fit: BoxFit.cover, - fallbackColor: AppColors.bar, + const ColoredBox( + key: Key('scrub-preview-background'), + color: AppColors.bar, ), if (stage == ScrubPreviewStage.sprite && spriteFrame != null) SpriteCropPreview( @@ -265,11 +260,7 @@ class SpriteCropPreview extends StatelessWidget { @override Widget build(BuildContext context) => CustomPaint( - painter: _SpriteCropPainter( - image: image, - sourceRect: sourceRect, - fit: fit, - ), + painter: _SpriteCropPainter(image: image, sourceRect: sourceRect, fit: fit), ); } diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 0f91a2d..f0bbf32 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -41,7 +41,6 @@ class SeekProgressBar extends StatefulWidget { this.thumbnailService, this.spriteAxisService, this.episodeId, - this.posterUrl = '', this.thumbnailsUrl, }); @@ -57,7 +56,6 @@ class SeekProgressBar extends StatefulWidget { final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; - final String posterUrl; final String? thumbnailsUrl; @override @@ -351,7 +349,6 @@ class _SeekProgressBarState extends State { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, - posterUrl: widget.posterUrl, position: previewPosition, duration: video.duration, ), diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 1571fda..f584ef6 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -249,9 +249,7 @@ void main() { }, ); - testWidgets('should_show_frame_card_with_cover_then_hide_on_release', ( - tester, - ) async { + testWidgets('should_show_frame_card_then_hide_on_release', (tester) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), @@ -272,7 +270,6 @@ void main() { valueListenable: video, semanticsLabel: 'Seek video', episodeId: 'episode-1', - posterUrl: '', thumbnailService: service, onSeek: (_) async {}, ), @@ -289,7 +286,7 @@ void main() { 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-background')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); expect(find.textContaining('/ 00:12'), findsOneWidget); expect( diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart index 663225b..4e45521 100644 --- a/test/playback/sprite_thumbnail_axis_test.dart +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -114,6 +114,32 @@ void main() { expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue); }); + testWidgets('should_keep_zero_frames_ready_across_neighbor_episodes', ( + tester, + ) async { + final loader = _FakeSpriteLoader(vtt: _spriteVtt); + final service = SpriteThumbnailAxisService( + loader: loader, + spriteCacheCapacity: 6, + ); + addTearDown(service.dispose); + const current = 'https://cdn.test/episode-1/thumbnails.vtt'; + const previous = 'https://cdn.test/episode-0/thumbnails.vtt'; + const next = 'https://cdn.test/episode-2/thumbnails.vtt'; + + await Future.wait([ + service.prepareAt(previous, Duration.zero), + service.prepareAt(current, Duration.zero), + service.prepareAt(next, Duration.zero), + ]); + + for (final url in [previous, current, next]) { + expect(service.hasAxis(url), isTrue); + expect(service.cachedFrameAt(url, Duration.zero), isNotNull); + } + expect(loader.spriteCalls, hasLength(6)); + }); + testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async { var now = DateTime(2026, 10, 22); final loader = _FakeSpriteLoader(vtt: _spriteVtt); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index 139e9fe..294e2d2 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -5,8 +5,9 @@ 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/core/widgets/remote_image.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart'; import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart'; +import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; const String _spriteVtt = ''' WEBVTT @@ -16,49 +17,46 @@ sprite_001.jpg#xywh=0,0,160,90 '''; void main() { - test('should_return_no_opening_frame_when_thumbnails_url_is_empty', () { - expect(playerOpeningFrame(null, posterUrl: ' '), isNull); - expect(playerOpeningFrame(' '), isNull); + testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', ( + tester, + ) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + + expect(playerOpeningFrame(null, spriteSource: service), isNull); + expect(playerOpeningFrame(' ', spriteSource: service), isNull); }); - test('should_keep_opening_frame_when_only_poster_is_available', () { - expect( - playerOpeningFrame(null, posterUrl: 'https://cdn.test/poster.jpg'), - isA(), - ); - }); - - testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async { + testWidgets('should_render_prewarmed_zero_frame_from_shared_cache', ( + tester, + ) async { final loader = _FakeSpriteLoader(); - final cache = OpeningFrameCache(); - addTearDown(cache.clear); + final service = SpriteThumbnailAxisService(loader: loader); + addTearDown(service.dispose); + const url = 'https://cdn.test/thumbnails.vtt'; + await service.prepareAt(url, Duration.zero); - final first = await cache.load( - 'https://cdn.test/thumbnails.vtt', - loader: loader, - ); - final second = await cache.load( - 'https://cdn.test/thumbnails.vtt', - loader: loader, + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame(thumbnailsUrl: url, spriteSource: service), + ), ); + await tester.pump(); - expect(first, isNotNull); - expect(second, same(first)); + expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); 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); + testWidgets('should_load_sprite_when_not_prewarmed', (tester) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); await tester.pumpWidget( MaterialApp( home: PlayerOpeningFrame( thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', - loader: loader, - cache: cache, + spriteSource: service, ), ), ); @@ -67,80 +65,26 @@ void main() { expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); }); - testWidgets('should_show_poster_while_sprite_is_loading', (tester) async { - final cache = OpeningFrameCache(); - addTearDown(cache.clear); + testWidgets('should_use_same_fit_for_sprite_and_video', (tester) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + const url = 'https://cdn.test/thumbnails.vtt'; + await service.prepareAt(url, Duration.zero); await tester.pumpWidget( MaterialApp( home: PlayerOpeningFrame( - thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', - posterUrl: 'https://cdn.test/poster.jpg', - loader: _FakeSpriteLoader(), - cache: cache, + thumbnailsUrl: url, + spriteSource: service, + fit: BoxFit.contain, ), ), ); - expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); - }); - - testWidgets('should_use_surface_fit_without_extra_frame_constraint', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: PlayerOpeningFrame( - posterUrl: 'https://cdn.test/poster.jpg', - ), - ), + final preview = tester.widget( + find.byKey(const Key('player-opening-sprite')), ); - - final poster = tester.widget( - find.byKey(const Key('player-opening-poster')), - ); - - expect(poster.fit, BoxFit.cover); - expect(find.byType(AspectRatio), findsNothing); - }); - - test('should_forward_video_fit_to_opening_frame', () { - final frame = playerOpeningFrame( - null, - posterUrl: 'https://cdn.test/poster.jpg', - fit: BoxFit.contain, - ); - - expect(frame, isA()); - expect((frame! as PlayerOpeningFrame).fit, BoxFit.contain); - }); - - testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', ( - tester, - ) async { - final loader = _FakeSpriteLoader(); - final cache = OpeningFrameCache(); - addTearDown(cache.clear); - Widget build() => MaterialApp( - home: PlayerOpeningFrame( - thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', - posterUrl: 'https://cdn.test/poster.jpg', - loader: loader, - cache: cache, - ), - ); - - await tester.pumpWidget(build()); - await tester.pumpAndSettle(); - expect(loader.vttCalls, 1); - expect(loader.spriteCalls, hasLength(1)); - - await tester.pumpWidget(build()); - await tester.pumpAndSettle(); - - expect(loader.vttCalls, 1); - expect(loader.spriteCalls, hasLength(1)); - expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); + expect(preview.fit, BoxFit.contain); }); } diff --git a/test/player/scrub_preview_card_test.dart b/test/player/scrub_preview_card_test.dart index edb7d62..116902b 100644 --- a/test/player/scrub_preview_card_test.dart +++ b/test/player/scrub_preview_card_test.dart @@ -67,13 +67,11 @@ void main() { await tester.pump(); expect(loader.calls, 1); - expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); }); - testWidgets('should_keep_poster_when_axis_and_local_frame_both_fail', ( - tester, - ) async { + testWidgets('should_keep_background_when_frames_both_fail', (tester) async { final loader = _CountingThumbnailLoader(); final localService = _localService(loader); final axis = _FakeAxisSource(axisReady: true); @@ -90,7 +88,7 @@ void main() { await tester.pump(); expect(loader.calls, 1); - expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); }); @@ -109,7 +107,6 @@ Widget _card({ spriteAxisService: axis, thumbnailsUrl: thumbnailsUrl, episodeId: 'episode-1', - posterUrl: '', position: const Duration(seconds: 1), duration: const Duration(seconds: 10), ), From 0cb0ab0613a7d3617c27546858e3785273138c52 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:39:31 +0800 Subject: [PATCH 08/42] =?UTF-8?q?fix(player):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=8D=95=E5=B1=82=E8=BF=87=E6=B8=A1=E5=B9=B6=E9=98=BB=E6=AD=A2?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E9=9B=86=E4=B8=B2=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/playback_session_controls.dart | 20 + lib/pages/player/player_logic.dart | 31 +- lib/pages/player/player_view.dart | 67 +-- .../player/widgets/player_video_surface.dart | 412 +++++++++++------- test/playback/playback_session_test.dart | 86 ++++ test/player/player_fullscreen_test.dart | 11 + test/player/player_logic_test.dart | 138 ++++++ test/player/player_video_surface_test.dart | 227 ++++------ 8 files changed, 657 insertions(+), 335 deletions(-) diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index e0a4045..293f070 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -10,6 +10,26 @@ extension PlaybackSessionControls on PlaybackSession { _warmNeighbors(this, preferredNext: forward); } + /// 选中锁定集时立即停播并释放所有原生播放器,不创建新片源。 + Future selectLocked( + int index, { + int? preloadIndex, + int? previousIndex, + }) { + if (_closed || index < 0 || index >= episodes.length) { + return Future.value(); + } + _generation++; + _selected = false; + _currentIndex = index; + _rememberNeighbors(preloadIndex, previousIndex); + _loading = false; + _error = null; + _userWantsPlay = false; + _notify(); + return _releasePlayers(); + } + /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 PlayerPort? handoffAt(int index) { final slot = _handoff; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 07b450a..a638617 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -100,6 +100,7 @@ class PlayerLogic extends ChangeNotifier { bool pip = false; bool pipPreparing = false; final Map pickedEpisode = {}; + final Set _unlockedEpisodeIds = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; @@ -238,7 +239,8 @@ class PlayerLogic extends ChangeNotifier { episodes: sessionEpisodes, factory: factory, // 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定 - canPlay: (episode) => episode.videoUrl.isNotEmpty, + canPlay: (episode) => + episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode), prepare: preparePlayer, activate: activatePlayer, coordinator: coordinator, @@ -428,6 +430,7 @@ class PlayerLogic extends ChangeNotifier { final localPath = await downloads?.findLocalPath(episode.id); if (request != pageRequest) return false; if (localPath != null) { + _unlockedEpisodeIds.add(episode.id); blockedTicket = null; blockedEpisodeId = null; qualities = const []; @@ -452,6 +455,7 @@ class PlayerLogic extends ChangeNotifier { final playUrl = preferred ?? ticket.playUrl; if (preferred == null) selectedQuality = 'AUTO'; if (ticket.accessible && playUrl.isNotEmpty) { + _unlockedEpisodeIds.add(episode.id); // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl( playUrl, @@ -467,7 +471,7 @@ class PlayerLogic extends ChangeNotifier { } catch (exception) { if (request != pageRequest) return false; // 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了 - if (episode.videoUrl.isNotEmpty) { + if (episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode)) { ticketError = null; return true; } @@ -566,6 +570,7 @@ class PlayerLogic extends ChangeNotifier { unlockError = service.unlockError; return false; } + _unlockedEpisodeIds.add(episode.id); final ready = await ensureTicket(index, forceRefresh: true); if (ready) { await _selectVisible(index); @@ -962,6 +967,22 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } + final episode = sessionEpisodes[index]; + if (isEpisodeLocked(episode)) { + blockedTicket = null; + blockedEpisodeId = episode.id; + ticketError = null; + await session.selectLocked( + index, + preloadIndex: nextVisibleIndex(index), + previousIndex: previousVisibleIndex(index), + ); + if (request != pageRequest || + feedRevisionAtStart != feedRevision || + !isMounted()) { + return; + } + } // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) final ready = await ensureTicket(index); if (request != pageRequest || @@ -973,6 +994,12 @@ class PlayerLogic extends ChangeNotifier { await _selectVisible(index); } + /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 + bool isEpisodeLocked(Episode episode) { + return (episode.locked || !episode.isFree) && + !_unlockedEpisodeIds.contains(episode.id); + } + /// 按可见队列选择一集,并预热上一集与下一集。 Future _selectVisible(int index) { return session.select( diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 90958a2..02d56ce 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -146,9 +146,8 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); - _spriteAxisService = SpriteThumbnailAxisService( - spriteCacheCapacity: 8, - )..addListener(_handleSpriteFrameChanged); + _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) + ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -389,10 +388,9 @@ class _PlayerPageState extends State continue; } final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl; - if (thumbnailsUrl != null) { - unawaited( - _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), - ); + if (thumbnailsUrl != null && + !logic.isEpisodeLocked(logic.sessionEpisodes[index])) { + unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } } } @@ -603,33 +601,36 @@ class _PlayerPageState extends State ) : displayed; final selected = index == logic.session.currentIndex; + final locked = + logic.isEpisodeLocked(episode) || + logic.session.isLocked || + logic.blockedEpisodeId == episode.id; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. - final port = - (selected ? logic.session.active : null) ?? - logic.session.preparedPlayerAt(index); + final port = locked + ? null + : (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; - final handoff = selected ? logic.session.handoffAt(index) : null; + final handoff = selected && !locked ? logic.session.handoffAt(index) : null; final handoffPort = handoff is VideoPlayerPort ? handoff : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; if (selected && videoReady) _logSwipeReveal(); - final ready = videoReady || port != null; + final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = - selected && logic.session.reusedWarmAt(index); + selected && !locked && logic.session.reusedWarmAt(index); final thumbnailsUrl = episode.thumbnailsUrl; final openingFrameReady = thumbnailsUrl != null && _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; if (thumbnailsUrl != null) { - unawaited( - _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), - ); + unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } - final locked = logic.session.isLocked || episode.locked; if (selected && + !locked && videoPort != null && videoPort.controller.value.isInitialized) { unawaited( @@ -649,20 +650,27 @@ class _PlayerPageState extends State color: AppColors.mediaBlack, ), PlayerVideoSurface( - controller: videoPort?.controller, - handoff: handoffPort?.controller, + controller: locked ? null : videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - holdOpeningUntilFirstFrame: logic.session.userWantsPlay, - preparedFrameAvailable: preparedFrameAvailable, - openingFrameReady: openingFrameReady, - openingFrame: playerOpeningFrame( - thumbnailsUrl, - spriteSource: _spriteAxisService, - fit: videoFit, - ), + warmFrameAvailable: preparedFrameAvailable, + thumbnailReady: openingFrameReady, + waitForPlaybackFrame: logic.session.userWantsPlay, + placeholder: locked + ? null + : handoffPort != null + ? ControllerFramePlaceholder( + controller: handoffPort.controller, + theater: theater, + ) + : playerOpeningFrame( + thumbnailsUrl, + spriteSource: _spriteAxisService, + fit: videoFit, + ), ), if (selected && + !locked && !theater && !ready && !logic.ticketLoading && @@ -884,10 +892,7 @@ class _PlayerPageState extends State controlsVisible: _chromeVisible, ), // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 - if (selected && - !theater && - !ready && - logic.blockedEpisodeId == episode.id) + if (selected && !theater && locked) PlayerBlockedNotice( l10n: l10n, episode: episode, diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 9392121..2aebe3a 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -6,37 +6,36 @@ import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; +const Duration _crossFadeDuration = Duration(milliseconds: 180); const double _errorHorizontal = 32; const int _errorMaxLines = 3; -/// 黑色底层与视频层共用同一控制器。清晰度切换时用仍在播放的旧画面盖住新纹理的黑帧。 +/// 播放区只维护一个占位和一个视频层,首帧确认后执行一次交叉淡化。 class PlayerVideoSurface extends StatefulWidget { const PlayerVideoSurface({ super.key, required this.controller, required this.videoUrl, required this.theater, - this.handoff, - this.openingFrame, - this.openingFrameReady = false, - this.holdOpeningUntilFirstFrame = false, - this.preparedFrameAvailable = false, + this.placeholder, + this.warmFrameAvailable = false, + this.thumbnailReady = false, + this.waitForPlaybackFrame = false, }); final VideoPlayerController? controller; - final VideoPlayerController? handoff; - /// 解码出第一帧之前垫上的视频首帧,没有则为空。 - final Widget? openingFrame; + /// 视频首帧未确认时唯一的占位 widget。 + final Widget? placeholder; - /// 当前集雪碧图 t=0 是否已在共享缓存中可用。 - final bool openingFrameReady; + /// 相邻集预热槽已可用时直接出视频,不挂载占位。 + final bool warmFrameAvailable; - /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 - final bool holdOpeningUntilFirstFrame; + /// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。 + final bool thumbnailReady; - /// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。 - final bool preparedFrameAvailable; + /// 自动播放中的新集等待 position 首次前进后再移交画面。 + final bool waitForPlaybackFrame; final String videoUrl; final bool theater; @@ -44,53 +43,87 @@ class PlayerVideoSurface extends StatefulWidget { State createState() => _PlayerVideoSurfaceState(); } -class _PlayerVideoSurfaceState extends State { - String? _lastDebugState; - String? _lastLayerState; - VideoPlayerController? _firstFrameController; +class _PlayerVideoSurfaceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _crossFade; + VideoPlayerController? _attachedController; + Widget? _placeholderSnapshot; + Size? _videoSize; bool _playbackStarted = false; - bool _firstFrameSeen = false; + bool _frameConfirmed = false; + bool _confirmationScheduled = false; + bool _transitioning = false; + bool _videoVisible = false; + String? _lastLayerState; + + @override + void initState() { + super.initState(); + _crossFade = AnimationController(vsync: this, duration: _crossFadeDuration) + ..addStatusListener(_handleFadeStatus); + _placeholderSnapshot = widget.placeholder; + _attachController(widget.controller); + final value = widget.controller?.value; + _updatePlaybackFlags(value); + _videoSize = value != null && value.isInitialized ? value.size : null; + if (_firstFrameConfirmed(value)) { + _confirmFirstFrame(initial: true); + } + } @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { - _lastDebugState = null; - _lastLayerState = null; - _firstFrameController = null; - _playbackStarted = false; - _firstFrameSeen = false; + _detachController(oldWidget.controller); + _resetForControllerChange(); + _attachController(widget.controller); + final value = widget.controller?.value; + _updatePlaybackFlags(value); + _videoSize = value != null && value.isInitialized ? value.size : null; + if (_firstFrameConfirmed(value)) _confirmFirstFrame(initial: true); + return; } + if (!_transitioning && + !_videoVisible && + oldWidget.placeholder != widget.placeholder) { + _placeholderSnapshot = widget.placeholder; + } + if (oldWidget.thumbnailReady != widget.thumbnailReady || + oldWidget.warmFrameAvailable != widget.warmFrameAvailable) { + final value = widget.controller?.value; + _updatePlaybackFlags(value); + if (!_frameConfirmed && _firstFrameConfirmed(value)) { + _confirmFirstFrame(); + } else { + _logLayer('placeholder', value); + } + } + } + + @override + void dispose() { + _detachController(widget.controller); + _crossFade + ..removeStatusListener(_handleFadeStatus) + ..dispose(); + super.dispose(); } @override Widget build(BuildContext context) { final controller = widget.controller; - if (controller == null) return _frame(null); - return ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) => _frame(value), - ); - } - - Widget _frame(VideoPlayerValue? value) { - final controller = widget.controller; - final handoff = _liveHandoff(controller); - final firstFrameSeen = _trackFrameState(controller, value); - final firstFrameReady = widget.preparedFrameAvailable || firstFrameSeen; - final textureReady = _shouldReveal(value, handoff != null); - final reveal = - textureReady && - (!widget.holdOpeningUntilFirstFrame || - widget.preparedFrameAvailable || - firstFrameReady || - _playbackStarted && - value?.isPlaying == false && - value?.isBuffering != true); - final showOpening = - widget.openingFrame != null && handoff == null && !reveal; - _logStateChange(value); - _logLayerState(value, handoff, reveal, firstFrameReady, showOpening); + final value = controller?.value; + final videoSize = _videoSize; + final showVideo = controller != null && videoSize != null; + final showPlaceholder = !_videoVisible && _placeholderSnapshot != null; + final video = showVideo + ? _VideoLayer( + controller: controller, + size: videoSize, + theater: widget.theater, + ) + : null; return Stack( fit: StackFit.expand, children: [ @@ -98,29 +131,18 @@ class _PlayerVideoSurfaceState extends State { key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), - if (showOpening) + if (showPlaceholder) KeyedSubtree( - key: const Key('player-opening-frame'), - child: widget.openingFrame!, + key: const Key('player-single-placeholder'), + child: _placeholderSnapshot!, ), - AnimatedOpacity( - key: const Key('player-video-opacity'), - opacity: reveal ? 1 : 0, - duration: Duration.zero, - child: reveal && controller != null - ? _VideoLayer( - controller: controller, - value: value!, - theater: widget.theater, - ) - : const SizedBox.expand(), - ), - if (handoff != null) - _VideoLayer( - key: const Key('player-video-handoff'), - controller: handoff, - value: handoff.value, - theater: widget.theater, + if (_videoVisible && video != null) + KeyedSubtree(key: const Key('player-video-layer'), child: video) + else if (_transitioning && video != null) + FadeTransition( + key: const Key('player-video-crossfade'), + opacity: _crossFade, + child: video, ), if (kDebugMode && value?.hasError == true) Center( @@ -140,134 +162,194 @@ 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; - return value.size.width > 0 && value.size.height > 0; + void _attachController(VideoPlayerController? controller) { + _attachedController = controller; + controller?.addListener(_handleControllerChanged); } - bool _trackFrameState( - VideoPlayerController? controller, - VideoPlayerValue? value, - ) { - if (!identical(_firstFrameController, controller)) { - _firstFrameController = controller; - _playbackStarted = false; - _firstFrameSeen = false; + void _detachController(VideoPlayerController? controller) { + controller?.removeListener(_handleControllerChanged); + if (_attachedController == controller) _attachedController = null; + } + + void _handleControllerChanged() { + final value = widget.controller?.value; + final nextSize = value != null && value.isInitialized ? value.size : null; + final sizeChanged = nextSize != _videoSize; + _updatePlaybackFlags(value); + _videoSize = nextSize; + if (!_frameConfirmed && _firstFrameConfirmed(value)) { + _scheduleFirstFrameConfirmation(); + } else if (sizeChanged && mounted) { + setState(() {}); } - if (value == null) return false; - _playbackStarted = _playbackStarted || value.isPlaying; - _firstFrameSeen = - _firstFrameSeen || value.position > Duration.zero || value.isCompleted; - return _firstFrameSeen; } - /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 - VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { - final handoff = widget.handoff; - if (handoff == null || identical(handoff, controller)) return null; - return handoff; + void _updatePlaybackFlags(VideoPlayerValue? value) { + _playbackStarted = _playbackStarted || value?.isPlaying == true; } - void _logStateChange(VideoPlayerValue? value) { + void _scheduleFirstFrameConfirmation() { + if (_confirmationScheduled || _frameConfirmed) return; + _confirmationScheduled = true; + if (kDebugMode) { + debugPrint( + '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + 'phase=first-frame-scheduled', + ); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (kDebugMode) { + debugPrint( + '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + 'phase=first-frame-callback mounted=$mounted', + ); + } + _confirmationScheduled = false; + if (!mounted || _frameConfirmed) return; + if (_firstFrameConfirmed(widget.controller?.value)) { + _confirmFirstFrame(); + } + }); + WidgetsBinding.instance.scheduleFrame(); + } + + bool _firstFrameConfirmed(VideoPlayerValue? value) { + if (value == null || + !value.isInitialized || + value.hasError || + value.size.width <= 0 || + value.size.height <= 0) { + return false; + } + if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true; + if (value.position > Duration.zero || value.isCompleted) return true; + return _playbackStarted && !value.isPlaying && !value.isBuffering; + } + + void _confirmFirstFrame({bool initial = false}) { + if (_frameConfirmed || _videoSize == null) return; + _frameConfirmed = true; + final canSkipPlaceholder = + widget.warmFrameAvailable || _placeholderSnapshot == null; + if (canSkipPlaceholder) { + if (initial) { + _videoVisible = true; + _placeholderSnapshot = null; + } else { + setState(() { + _videoVisible = true; + _placeholderSnapshot = null; + }); + } + _logLayer('video', widget.controller?.value); + return; + } + if (initial) { + _transitioning = true; + _logLayer('fade-start', widget.controller?.value); + _crossFade.forward(from: 0); + } else { + setState(() { + _transitioning = true; + _placeholderSnapshot = widget.placeholder; + }); + _logLayer('fade-start', widget.controller?.value); + _crossFade.forward(from: 0); + } + } + + void _handleFadeStatus(AnimationStatus status) { + if (status != AnimationStatus.completed || !mounted) return; + setState(() { + _transitioning = false; + _videoVisible = true; + _placeholderSnapshot = null; + }); + _logLayer('fade-end', widget.controller?.value); + } + + void _resetForControllerChange() { + _crossFade.reset(); + _placeholderSnapshot = widget.placeholder; + _videoSize = null; + _playbackStarted = false; + _frameConfirmed = false; + _confirmationScheduled = false; + _transitioning = false; + _videoVisible = false; + _lastLayerState = null; + } + + void _logLayer(String phase, VideoPlayerValue? value) { if (!kDebugMode) return; - final status = _debugStatus(value); - final signature = - '$status|${value?.isInitialized ?? false}|' - '${value?.errorDescription ?? 'none'}'; - if (signature == _lastDebugState) return; - _lastDebugState = signature; - final source = widget.controller?.dataSource ?? widget.videoUrl; - debugPrint( - '[video] init=${value?.isInitialized ?? false} ' - 'err=${value?.errorDescription ?? 'none'} host=${_host(source)}', - ); - } - - void _logLayerState( - VideoPlayerValue? value, - VideoPlayerController? handoff, - bool reveal, - bool firstFrameReady, - bool showOpening, - ) { - if (!kDebugMode) return; - final reason = handoff != null - ? 'handoff' - : !_shouldReveal(value, handoff != null) - ? 'uninitialized' - : widget.preparedFrameAvailable - ? 'warm-frame' - : !firstFrameReady && widget.holdOpeningUntilFirstFrame - ? 'awaiting-first-frame' - : 'reveal'; - final fallback = handoff != null - ? 'handoff' - : showOpening - ? widget.openingFrameReady - ? 'thumbnails-first-frame' - : 'black' - : reveal + final fallback = phase == 'video' || phase == 'fade-end' ? 'video' + : _placeholderSnapshot == null + ? 'black' + : widget.thumbnailReady + ? 'thumbnails-first-frame' : 'black'; final signature = - 'warm=${widget.preparedFrameAvailable ? 1 : 0} ' - 'frame=${firstFrameReady ? 1 : 0} ' - 'thumbnails=${widget.openingFrameReady ? 'ready' : 'pending'} ' + 'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} ' + 'frame=${_frameConfirmed ? 1 : 0} ' + 'thumbnails=${widget.thumbnailReady ? 'ready' : 'pending'} ' 'fallback=$fallback ' - 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'init=${value?.isInitialized == true ? 1 : 0} ' + 'position=${value?.position.inMilliseconds ?? 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' - 'buffering=${value?.isBuffering == true ? 1 : 0} ' - 'reason=$reason'; + 'buffering=${value?.isBuffering == true ? 1 : 0}'; if (signature == _lastLayerState) return; _lastLayerState = signature; - debugPrint('[player-layer] $signature'); - } - - String _debugStatus(VideoPlayerValue? value) { - if (value == null) return 'loading'; - if (value.hasError) { - return 'error: ${value.errorDescription ?? 'unknown'}'; - } - if (value.isBuffering) return 'buffering'; - if (value.isInitialized) return 'initialized'; - return 'loading'; - } - - String _host(String source) { - final uri = Uri.tryParse(source); - if (uri == null || uri.host.isEmpty) return 'unknown'; - final port = uri.hasPort - ? uri.port - : uri.scheme == 'https' - ? 443 - : 80; - return '${uri.host}:$port'; + debugPrint( + '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature', + ); } } -class _VideoLayer extends StatelessWidget { - const _VideoLayer({ +/// 将旧 controller 作为唯一占位,不额外叠加 handoff 层。 +class ControllerFramePlaceholder extends StatelessWidget { + const ControllerFramePlaceholder({ super.key, required this.controller, - required this.value, required this.theater, }); final VideoPlayerController controller; - final VideoPlayerValue value; final bool theater; @override Widget build(BuildContext context) { + final size = controller.value.size; return FittedBox( fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( - width: value.size.width, - height: value.size.height, + width: size.width, + height: size.height, child: VideoPlayer(controller, key: ValueKey(controller)), ), ); } } + +class _VideoLayer extends StatelessWidget { + const _VideoLayer({ + required this.controller, + required this.size, + required this.theater, + }); + + final VideoPlayerController controller; + final Size size; + final bool theater; + + @override + Widget build(BuildContext context) => FittedBox( + fit: theater ? BoxFit.contain : BoxFit.cover, + child: SizedBox( + width: size.width, + height: size.height, + child: VideoPlayer(controller, key: ValueKey(controller)), + ), + ); +} diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index d9905c2..7595d08 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,6 +32,92 @@ void main() { await session.close(); }); + test('should_release_previous_player_immediately_when_selecting_locked', () async { + final lockedEpisodes = [ + Episode( + id: 'free', + number: 1, + title: 'Free', + isFree: true, + videoUrl: 'https://example.test/free.mp4', + ), + Episode( + id: 'locked', + number: 2, + title: 'Locked', + isFree: false, + locked: true, + videoUrl: 'https://example.test/should-not-play.mp4', + ), + Episode( + id: 'next', + number: 3, + title: 'Next', + isFree: true, + videoUrl: 'https://example.test/next.mp4', + ), + ]; + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: lockedEpisodes, + factory: factory.create, + canPlay: (episode) => !episode.locked, + ); + await session.select(0, preloadIndex: 1); + final previous = factory.created.single; + + await session.selectLocked(1, preloadIndex: 2, previousIndex: 0); + await session.playbackSettled; + + expect(session.active, isNull); + expect(previous.pauseCalls, greaterThan(0)); + expect(previous.disposed, isTrue); + expect(factory.live, 0); + expect(factory.created, hasLength(1)); + + await session.select(2, previousIndex: 1); + expect(session.active, same(factory.created.last)); + expect(factory.created.last.playCalls, greaterThan(0)); + await session.close(); + }); + + test('should_play_locked_episode_only_after_it_is_unlocked', () async { + final lockedEpisodes = [ + Episode( + id: 'free', + number: 1, + title: 'Free', + isFree: true, + videoUrl: 'https://example.test/free.mp4', + ), + Episode( + id: 'locked', + number: 2, + title: 'Locked', + isFree: false, + locked: true, + videoUrl: 'https://example.test/locked.mp4', + ), + ]; + final unlocked = {}; + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: lockedEpisodes, + factory: factory.create, + canPlay: (episode) => episode.isFree || unlocked.contains(episode.id), + ); + await session.select(0); + await session.selectLocked(1); + expect(session.active, isNull); + + unlocked.add('locked'); + await session.select(1); + + expect(session.active, same(factory.created.last)); + expect(factory.created.last.playCalls, greaterThan(0)); + await session.close(); + }); + test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async { final factory = FakeFactory(holdFirstInitialize: true); final session = PlaybackSession( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b10bc7c..354f4da 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1031,6 +1031,14 @@ void main() { await _pumpApp( tester, catalog: FakeCatalogRepository(dramas: const [_lockedDrama]), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-demo-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), ); await _openSeriesPlayer( tester, @@ -1040,6 +1048,9 @@ void main() { expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsNothing); expect( tester .widget(find.byKey(const Key('player-progress'))) diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index e9498ac..55c4a2b 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -11,12 +11,15 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/models/download_license.dart'; import 'package:fruitshorts/core/data/models/download_policy.dart'; import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; @@ -46,6 +49,37 @@ Drama _drama( ], ); +Drama _gateDrama() => const Drama( + id: 'gate', + title: 'Gate', + synopsis: 'Gate synopsis', + posterUrl: '/files/gate.png', + episodes: [ + Episode( + id: 'gate-1', + number: 1, + title: 'Free', + isFree: true, + videoUrl: 'https://example.test/gate-1.mp4', + ), + Episode( + id: 'gate-2', + number: 2, + title: 'Locked', + isFree: false, + locked: true, + videoUrl: 'https://example.test/gate-2.mp4', + ), + Episode( + id: 'gate-3', + number: 3, + title: 'Next', + isFree: true, + videoUrl: 'https://example.test/gate-3.mp4', + ), + ], +); + AndroidPictureInPicture _pip() => AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip')); @@ -53,11 +87,13 @@ PlayerLogic _logic( FakeWatchHistory history, { PlaybackRepository? playback, PlaybackPositionCache? positionCache, + WalletController? wallet, Future Function()? waitForFrame, }) => PlayerLogic( history: history, playback: playback ?? FakePlaybackRepository(), pictureInPicture: _pip(), + wallet: wallet, positionCache: positionCache, waitForFrame: waitForFrame ?? () async {}, ); @@ -581,6 +617,88 @@ void main() { await _drain(logic); }); + test('should_pause_and_release_when_swiping_to_locked_episode', () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(); + final logic = _logic(FakeWatchHistory(), playback: playback); + logic.attach( + PlayerPage(drama: _gateDrama(), initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + final previous = factory.created.single; + + playback.ticket = const PlayTicket( + episodeId: 'gate-2', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ); + await logic.selectEpisode(1); + await _drain(logic); + + expect(previous.pauses, greaterThan(0)); + expect(previous.disposed, isTrue); + expect(logic.session.active, isNull); + expect(factory.live, 0); + expect(factory.created, hasLength(1)); + expect(logic.blockedEpisodeId, 'gate-2'); + + playback.ticket = null; + await logic.selectEpisode(2); + await _drain(logic); + expect(logic.session.active, isNotNull); + expect(factory.created.last.plays, greaterThan(0)); + + logic.dispose(); + await _drain(logic); + }); + + test('should_start_locked_episode_after_unlock_succeeds', () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(); + final wallet = WalletController(_UnlockCommerceRepository()); + final logic = _logic( + FakeWatchHistory(), + playback: playback, + wallet: wallet, + ); + logic.attach( + PlayerPage(drama: _gateDrama(), initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + playback.ticket = const PlayTicket( + episodeId: 'gate-2', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ); + await logic.selectEpisode(1); + await _drain(logic); + expect(logic.session.active, isNull); + + playback.ticket = const PlayTicket( + episodeId: 'gate-2', + accessible: true, + accessType: PlayTicket.coinRequired, + playUrl: '/files/gate-2.mp4', + ); + final unlocked = await logic.unlockEpisode(1); + await _drain(logic); + + expect(unlocked, isTrue); + expect(logic.isEpisodeLocked(logic.sessionEpisodes[1]), isFalse); + final active = logic.session.active; + expect(active, isA<_RecordingPlayer>()); + expect((active! as _RecordingPlayer).plays, greaterThan(0)); + + logic.dispose(); + await _drain(logic); + }); + test('should_preload_only_what_follows_when_starting_from_a_later_index', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); @@ -1403,3 +1521,23 @@ class _VideoRecordingPlayer extends VideoPlayerPort { await controller.dispose(); } } + +class _UnlockCommerceRepository implements CommerceRepository { + @override + Future fetchWallet() async => const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 100, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ); + + @override + Future unlock(String episodeId) async => const UnlockResult( + unlocked: true, + accessType: PlayTicket.coinRequired, + coinCost: 30, + coinBalance: 70, + ); +} diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 9f70bee..2b3ba99 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -7,179 +7,130 @@ import 'package:video_player/video_player.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; void main() { - testWidgets('should_keep_fallback_visible_before_video_is_ready', ( + testWidgets('should_show_black_without_placeholder_before_video', ( tester, ) async { await _pumpSurface(tester); expect(_fallbackColor(tester), Colors.black); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect(_opacity(tester), 0); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsNothing); }); - testWidgets('should_show_opening_frame_before_decode_is_ready', ( + testWidgets('should_show_exactly_one_placeholder_before_first_frame', ( tester, ) async { + await _pumpSurface(tester, placeholder: _placeholder('opening')); + + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('opening')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsNothing); + }); + + testWidgets('should_skip_placeholder_for_warm_controller', (tester) async { await _pumpSurface( tester, - openingFrame: const ColoredBox( - key: Key('fake-opening'), - color: Colors.red, - ), + placeholder: _placeholder('opening'), + warmFrameAvailable: true, + thumbnailReady: true, + value: _initializedValue, ); - expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); - expect(find.byKey(const Key('fake-opening')), findsOneWidget); - expect(_opacity(tester), 0); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); }); - 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_show_cover_until_first_playback_frame', ( + testWidgets('should_cross_fade_once_after_first_frame_is_confirmed', ( tester, ) async { final controller = await _pumpSurface( tester, - openingFrame: const ColoredBox(color: Colors.red), - holdOpeningUntilFirstFrame: true, - ); - expect(_opacity(tester), 0); - - controller.value = const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration.zero, - isInitialized: true, - isPlaying: true, + placeholder: _placeholder('opening'), + thumbnailReady: true, + waitForPlaybackFrame: true, ); + controller.value = _readyValue(position: Duration.zero, playing: true); await tester.pump(); - expect(_opacity(tester), 0); - expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); - controller.value = const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration(milliseconds: 1), - isInitialized: true, - isPlaying: true, + controller.value = _readyValue( + position: const Duration(milliseconds: 1), + playing: true, ); await tester.pump(); + await tester.pump(); + await tester.pump(); - expect(_opacity(tester), 1); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect( - tester - .widget( - find.byKey(const Key('player-video-opacity')), - ) - .duration, - Duration.zero, + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget); + expect(find.byKey(const Key('player-video-layer')), findsNothing); + expect(_crossFadeValue(tester), 0); + + await tester.pump(const Duration(milliseconds: 90)); + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget); + expect(_crossFadeValue(tester), greaterThan(0)); + expect(_crossFadeValue(tester), lessThan(1)); + + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsOneWidget); + + controller.value = _readyValue( + position: const Duration(seconds: 1), + playing: true, ); + await tester.pump(); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsOneWidget); }); - testWidgets('should_skip_cover_when_prewarmed_frame_is_available', ( - tester, - ) async { - await _pumpSurface( - tester, - openingFrame: const ColoredBox(color: Colors.red), - holdOpeningUntilFirstFrame: true, - preparedFrameAvailable: true, - value: const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration.zero, - isInitialized: true, - isPlaying: false, - ), - ); - - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect(_opacity(tester), 1); - }); - - testWidgets('should_show_video_error_above_retained_fallback', ( - tester, - ) async { + testWidgets('should_keep_placeholder_above_video_error', (tester) async { await _pumpSurface( tester, + placeholder: _placeholder('opening'), value: const VideoPlayerValue.erroneous('No route to host'), ); + expect(find.byKey(const Key('opening')), findsOneWidget); expect(find.byKey(const Key('player-video-error')), findsOneWidget); expect(find.text('No route to host'), findsOneWidget); - expect(_fallbackColor(tester), Colors.black); - expect(_opacity(tester), 0); - }); - - testWidgets('should_cover_new_video_only_while_handoff_is_held', ( - tester, - ) async { - final current = VideoPlayerController.networkUrl( - Uri.parse('http://192.168.31.187:8081/files/720.m3u8'), - ); - final previous = VideoPlayerController.networkUrl( - Uri.parse('http://192.168.31.187:8081/files/360.m3u8'), - ); - addTearDown(current.dispose); - addTearDown(previous.dispose); - const ready = VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration(seconds: 3), - isInitialized: true, - isPlaying: true, - ); - current.value = ready; - previous.value = ready; - - Future pump(VideoPlayerController? handoff) { - return tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: PlayerVideoSurface( - controller: current, - handoff: handoff, - videoUrl: current.dataSource, - theater: false, - openingFrame: const ColoredBox(color: Colors.red), - ), - ), - ), - ); - } - - await pump(previous); - expect(find.byKey(const Key('player-video-handoff')), findsOneWidget); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect(_opacity(tester), 1); - - await pump(null); - expect(find.byKey(const Key('player-video-handoff')), findsNothing); - expect(find.byType(VideoPlayer), findsOneWidget); }); } +Widget _placeholder(String key) => ColoredBox(key: Key(key), color: Colors.red); + +const VideoPlayerValue _initializedValue = VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration.zero, + isInitialized: true, + isPlaying: false, +); + +VideoPlayerValue _readyValue({ + required Duration position, + required bool playing, +}) => VideoPlayerValue( + duration: const Duration(minutes: 1), + size: const Size(1280, 720), + position: position, + isInitialized: true, + isPlaying: playing, +); + Future _pumpSurface( WidgetTester tester, { VideoPlayerValue? value, - Widget? openingFrame, - bool holdOpeningUntilFirstFrame = false, - bool preparedFrameAvailable = false, + Widget? placeholder, + bool warmFrameAvailable = false, + bool thumbnailReady = false, + bool waitForPlaybackFrame = false, }) async { final controller = VideoPlayerController.networkUrl( Uri.parse('http://192.168.31.187:8081/files/video.mp4'), @@ -193,9 +144,10 @@ Future _pumpSurface( controller: controller, videoUrl: controller.dataSource, theater: false, - openingFrame: openingFrame, - holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame, - preparedFrameAvailable: preparedFrameAvailable, + placeholder: placeholder, + warmFrameAvailable: warmFrameAvailable, + thumbnailReady: thumbnailReady, + waitForPlaybackFrame: waitForPlaybackFrame, ), ), ), @@ -210,8 +162,9 @@ Color _fallbackColor(WidgetTester tester) { .color; } -double _opacity(WidgetTester tester) { +double _crossFadeValue(WidgetTester tester) { return tester - .widget(find.byKey(const Key('player-video-opacity'))) - .opacity; + .widget(find.byKey(const Key('player-video-crossfade'))) + .opacity + .value; } From 2fef7470e02cade9f5d950e0d5cb45065ddae094 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:58:00 +0800 Subject: [PATCH 09/42] =?UTF-8?q?fix(player):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=AB=98=E6=B8=85=E9=A6=96=E5=B8=A7=E5=B9=B6=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E5=88=87=E9=9B=86=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/drama.dart | 7 +- lib/core/data/models/play_ticket.dart | 3 + lib/pages/player/player_logic.dart | 1 + lib/pages/player/player_view.dart | 55 ++++++++-- .../player/widgets/player_opening_frame.dart | 102 ++++++++++++++---- .../player/widgets/player_video_surface.dart | 34 +++++- test/playback/sprite_thumbnail_axis_test.dart | 7 ++ test/player/player_fullscreen_test.dart | 6 ++ test/player/player_logic_test.dart | 29 +++++ test/player/player_opening_frame_test.dart | 47 ++++++++ 10 files changed, 258 insertions(+), 33 deletions(-) diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 82679fc..0ca516e 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -21,6 +21,7 @@ class Episode { this.likeCount, this.commentCount, this.thumbnailsUrl, + this.posterUrl, }); final String id; @@ -37,12 +38,14 @@ class Episode { final int? likeCount; final int? commentCount; final String? thumbnailsUrl; + final String? posterUrl; Episode withVideoUrl( String url, { int? likeCount, int? commentCount, String? thumbnailsUrl, + String? posterUrl, }) => Episode( id: id, number: number, @@ -54,6 +57,7 @@ class Episode { likeCount: likeCount ?? this.likeCount, commentCount: commentCount ?? this.commentCount, thumbnailsUrl: thumbnailsUrl ?? this.thumbnailsUrl, + posterUrl: posterUrl ?? this.posterUrl, ); factory Episode.fromJson(Map json) { @@ -68,6 +72,7 @@ class Episode { likeCount: _optionalInt(json['likeCount']), commentCount: _optionalInt(json['commentCount']), thumbnailsUrl: _optionalString(json['thumbnailsUrl']), + posterUrl: _optionalString(json['posterUrl']), ); } } @@ -324,4 +329,4 @@ String _genreText(List> types, Object? fallback) { final joined = names.join(' / '); if (joined.isNotEmpty) return joined; return fallback is String ? fallback : ''; -} \ No newline at end of file +} diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index 0094ba4..353fa86 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -16,6 +16,7 @@ class PlayTicket { this.qualities = const [], this.tracks = const [], this.thumbnailsUrl, + this.posterUrl, this.likeCount, this.commentCount, }); @@ -46,6 +47,7 @@ class PlayTicket { /// 各档签名地址。AUTO 是主列表,点选其它档位时换成对应地址。 final List tracks; final String? thumbnailsUrl; + final String? posterUrl; final int? likeCount; final int? commentCount; @@ -70,6 +72,7 @@ class PlayTicket { .toList(), tracks: _tracks(json['tracks'], json['playUrl'] as String? ?? ''), thumbnailsUrl: _optionalString(json['thumbnailsUrl']), + posterUrl: _optionalString(json['posterUrl']), likeCount: json['likeCount'] == null ? null : jsonToInt(json['likeCount']), diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index a638617..fa56e6f 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -462,6 +462,7 @@ class PlayerLogic extends ChangeNotifier { likeCount: ticket.likeCount, commentCount: ticket.commentCount, thumbnailsUrl: ticket.thumbnailsUrl, + posterUrl: ticket.posterUrl, ); return true; } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 02d56ce..8a001ce 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -127,6 +127,7 @@ class _PlayerPageState extends State DateTime? _lastDoubleTapAt; DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; + final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -376,7 +377,7 @@ class _PlayerPageState extends State if (mounted) setState(() {}); } - void _prewarmAdjacentThumbnails() { + void _prewarmAdjacentFrames() { final current = logic.session.currentIndex; final indices = [ current, @@ -387,9 +388,24 @@ class _PlayerPageState extends State if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { continue; } - final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl; - if (thumbnailsUrl != null && - !logic.isEpisodeLocked(logic.sessionEpisodes[index])) { + final episode = logic.sessionEpisodes[index]; + if (logic.isEpisodeLocked(episode)) continue; + final posterUrl = episode.posterUrl?.trim() ?? ''; + if (posterUrl.isNotEmpty) { + final imageUrl = AppConfig.mediaUrl(posterUrl); + if (_precachedPosterUrls.add(imageUrl)) { + unawaited( + precacheImage(NetworkImage(imageUrl), context).catchError(( + Object _, + ) { + _precachedPosterUrls.remove(imageUrl); + }), + ); + } + continue; + } + final thumbnailsUrl = episode.thumbnailsUrl; + if (thumbnailsUrl != null && thumbnailsUrl.isNotEmpty) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } } @@ -477,7 +493,7 @@ class _PlayerPageState extends State final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { - _prewarmAdjacentThumbnails(); + _prewarmAdjacentFrames(); final pages = NotificationListener( onNotification: _handlePageScroll, child: PageView.builder( @@ -592,12 +608,17 @@ class _PlayerPageState extends State final displayed = widget.homeMode ? logic.displayEpisode(drama, sessionEpisode) : sessionEpisode; - final episode = + final needsSessionThumbnails = (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && - sessionEpisode.thumbnailsUrl != null + sessionEpisode.thumbnailsUrl != null; + final needsSessionPoster = + (displayed.posterUrl == null || displayed.posterUrl!.isEmpty) && + sessionEpisode.posterUrl != null; + final episode = needsSessionThumbnails || needsSessionPoster ? displayed.withVideoUrl( displayed.videoUrl, thumbnailsUrl: sessionEpisode.thumbnailsUrl, + posterUrl: sessionEpisode.posterUrl, ) : displayed; final selected = index == logic.session.currentIndex; @@ -621,12 +642,19 @@ class _PlayerPageState extends State final theater = logic.pip || logic.pipPreparing; final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = - selected && !locked && logic.session.reusedWarmAt(index); + !locked && + videoReady && + (selected + ? logic.session.reusedWarmAt(index) + : logic.session.preparedPlayerAt(index) != null); + final warmSlotReused = logic.session.reusedWarmAt(index); final thumbnailsUrl = episode.thumbnailsUrl; + final posterUrl = episode.posterUrl; + final posterReady = posterUrl?.trim().isNotEmpty ?? false; final openingFrameReady = thumbnailsUrl != null && _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; - if (thumbnailsUrl != null) { + if (!posterReady && thumbnailsUrl != null) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } if (selected && @@ -654,7 +682,15 @@ class _PlayerPageState extends State videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, warmFrameAvailable: preparedFrameAvailable, + warmSlotReused: warmSlotReused, thumbnailReady: openingFrameReady, + posterReady: posterReady, + fallback: handoffPort != null + ? PlayerOpeningFallback.handoff + : playerOpeningFallback( + posterUrl: posterUrl, + thumbnailsUrl: thumbnailsUrl, + ), waitForPlaybackFrame: logic.session.userWantsPlay, placeholder: locked ? null @@ -665,6 +701,7 @@ class _PlayerPageState extends State ) : playerOpeningFrame( thumbnailsUrl, + posterUrl: posterUrl, spriteSource: _spriteAxisService, fit: videoFit, ), diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index 529f7a4..b685c64 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -4,20 +4,42 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../../core/config/app_config.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import 'scrub_preview_card.dart'; -/// 当前集雪碧图首帧在视频纹理未就绪时占位;空地址交还纯黑底。 +enum PlayerOpeningFallback { handoff, poster, thumbnails, black } + +PlayerOpeningFallback playerOpeningFallback({ + String? posterUrl, + String? thumbnailsUrl, +}) { + if (posterUrl?.trim().isNotEmpty ?? false) { + return PlayerOpeningFallback.poster; + } + if (thumbnailsUrl?.trim().isNotEmpty ?? false) { + return PlayerOpeningFallback.thumbnails; + } + return PlayerOpeningFallback.black; +} + +/// 当前集首帧在视频纹理未就绪时占位;空地址交还纯黑底。 Widget? playerOpeningFrame( String? thumbnailsUrl, { + String? posterUrl, required SpriteThumbnailAxisSource spriteSource, BoxFit fit = BoxFit.cover, }) { - final url = thumbnailsUrl?.trim() ?? ''; - if (url.isEmpty) return null; + final thumbnails = thumbnailsUrl?.trim() ?? ''; + final poster = posterUrl?.trim() ?? ''; + if (playerOpeningFallback(posterUrl: poster, thumbnailsUrl: thumbnails) == + PlayerOpeningFallback.black) { + return null; + } return PlayerOpeningFrame( - thumbnailsUrl: url, + thumbnailsUrl: thumbnails, + posterUrl: poster, spriteSource: spriteSource, fit: fit, ); @@ -29,9 +51,13 @@ class PlayerOpeningFrame extends StatefulWidget { super.key, required this.thumbnailsUrl, required this.spriteSource, + this.posterUrl, this.fit = BoxFit.cover, }); + /// 当前视频原始分辨率首帧地址,优先于雪碧图。 + final String? posterUrl; + /// 当前视频雪碧图 WebVTT 地址。 final String thumbnailsUrl; @@ -46,26 +72,29 @@ class PlayerOpeningFrame extends StatefulWidget { } class _PlayerOpeningFrameState extends State { + bool _posterFailed = false; + @override void initState() { super.initState(); widget.spriteSource.addListener(_handleSpriteChanged); - unawaited( - widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), - ); + _prepareSpriteIfNeeded(); } @override void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.posterUrl != widget.posterUrl) { + _posterFailed = false; + } if (oldWidget.spriteSource != widget.spriteSource) { oldWidget.spriteSource.removeListener(_handleSpriteChanged); widget.spriteSource.addListener(_handleSpriteChanged); + _prepareSpriteIfNeeded(); } - if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { - unawaited( - widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), - ); + if (oldWidget.posterUrl != widget.posterUrl || + oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { + _prepareSpriteIfNeeded(); } } @@ -79,19 +108,56 @@ class _PlayerOpeningFrameState extends State { if (mounted) setState(() {}); } + bool get _hasPoster => + !_posterFailed && (widget.posterUrl?.trim().isNotEmpty ?? false); + + void _prepareSpriteIfNeeded() { + if (_hasPoster || widget.thumbnailsUrl.trim().isEmpty) return; + unawaited( + widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), + ); + } + + Widget _handlePosterError() { + if (!_posterFailed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _posterFailed) return; + setState(() => _posterFailed = true); + _prepareSpriteIfNeeded(); + }); + } + return _spriteOrBlack(); + } + + Widget _spriteOrBlack() { + final frame = widget.spriteSource.cachedFrameAt( + widget.thumbnailsUrl, + Duration.zero, + ); + if (frame == null) return const SizedBox.expand(); + return SpriteCropPreview( + key: const Key('player-opening-sprite'), + image: frame.image, + sourceRect: frame.sourceRect, + fit: widget.fit, + ); + } + @override Widget build(BuildContext context) => Stack( fit: StackFit.expand, children: [ const ColoredBox(color: AppColors.mediaBlack), - if (widget.spriteSource.cachedFrameAt(widget.thumbnailsUrl, Duration.zero) - case final frame?) - SpriteCropPreview( - key: const Key('player-opening-sprite'), - image: frame.image, - sourceRect: frame.sourceRect, + if (_hasPoster) + Image.network( + AppConfig.mediaUrl(widget.posterUrl!.trim()), + key: const Key('player-opening-poster'), fit: widget.fit, - ), + gaplessPlayback: true, + errorBuilder: (_, _, _) => _handlePosterError(), + ) + else + _spriteOrBlack(), ], ); } diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 2aebe3a..ec4ca86 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; +import 'player_opening_frame.dart'; const Duration _crossFadeDuration = Duration(milliseconds: 180); const double _errorHorizontal = 32; @@ -20,6 +21,9 @@ class PlayerVideoSurface extends StatefulWidget { this.placeholder, this.warmFrameAvailable = false, this.thumbnailReady = false, + this.posterReady = false, + this.fallback = PlayerOpeningFallback.black, + this.warmSlotReused = false, this.waitForPlaybackFrame = false, }); @@ -34,6 +38,15 @@ class PlayerVideoSurface extends StatefulWidget { /// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。 final bool thumbnailReady; + /// 当前集高清 poster 是否已下发,仅用于调试判定链。 + final bool posterReady; + + /// 首个视频帧确认前实际选择的唯一占位来源。 + final PlayerOpeningFallback fallback; + + /// 当前集是否为相邻集预热槽晋升而来。 + final bool warmSlotReused; + /// 自动播放中的新集等待 position 首次前进后再移交画面。 final bool waitForPlaybackFrame; final String videoUrl; @@ -90,6 +103,9 @@ class _PlayerVideoSurfaceState extends State _placeholderSnapshot = widget.placeholder; } if (oldWidget.thumbnailReady != widget.thumbnailReady || + oldWidget.posterReady != widget.posterReady || + oldWidget.fallback != widget.fallback || + oldWidget.warmSlotReused != widget.warmSlotReused || oldWidget.warmFrameAvailable != widget.warmFrameAvailable) { final value = widget.controller?.value; _updatePlaybackFlags(value); @@ -285,14 +301,12 @@ class _PlayerVideoSurfaceState extends State if (!kDebugMode) return; final fallback = phase == 'video' || phase == 'fade-end' ? 'video' - : _placeholderSnapshot == null - ? 'black' - : widget.thumbnailReady - ? 'thumbnails-first-frame' - : 'black'; + : _fallbackLabel(widget.fallback); final signature = 'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} ' + 'reusedWarm=${widget.warmSlotReused ? 1 : 0} ' 'frame=${_frameConfirmed ? 1 : 0} ' + 'poster=${widget.posterReady ? 'ready' : 'none'} ' 'thumbnails=${widget.thumbnailReady ? 'ready' : 'pending'} ' 'fallback=$fallback ' 'init=${value?.isInitialized == true ? 1 : 0} ' @@ -305,6 +319,16 @@ class _PlayerVideoSurfaceState extends State '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature', ); } + + String _fallbackLabel(PlayerOpeningFallback fallback) { + if (_placeholderSnapshot == null) return 'black'; + return switch (fallback) { + PlayerOpeningFallback.handoff => 'handoff', + PlayerOpeningFallback.poster => 'poster', + PlayerOpeningFallback.thumbnails => 'thumbnails-first-frame', + PlayerOpeningFallback.black => 'black', + }; + } } /// 将旧 controller 作为唯一占位,不额外叠加 handoff 层。 diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart index 4e45521..90cba24 100644 --- a/test/playback/sprite_thumbnail_axis_test.dart +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -199,19 +199,26 @@ void main() { 'title': 'episode', 'isFree': true, 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + 'posterUrl': 'https://cdn.test/poster.jpg?e=1&s=x', }); final ticket = PlayTicket.fromJson(const { 'episodeId': '11', 'accessible': true, 'accessType': 'FREE', 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + 'posterUrl': 'https://cdn.test/poster.jpg?e=1&s=x', }); expect(episode.thumbnailsUrl, ticket.thumbnailsUrl); + expect(episode.posterUrl, ticket.posterUrl); expect( episode.withVideoUrl('file:///video.mp4').thumbnailsUrl, episode.thumbnailsUrl, ); + expect( + episode.withVideoUrl('file:///video.mp4').posterUrl, + episode.posterUrl, + ); }); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 354f4da..7704bfc 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1037,6 +1037,8 @@ void main() { accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, + posterUrl: '/files/locked-first-frame.jpg', + thumbnailsUrl: '/files/locked-thumbnails.vtt', ), ), ); @@ -1049,6 +1051,8 @@ void main() { expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-opening-poster')), findsNothing); + expect(find.byKey(const Key('player-opening-sprite')), findsNothing); expect(find.byKey(const Key('player-video-crossfade')), findsNothing); expect(find.byKey(const Key('player-video-layer')), findsNothing); expect( @@ -1487,6 +1491,8 @@ const _lockedDrama = Drama( isFree: false, locked: true, videoUrl: 'https://example.test/locked-demo-1.mp4', + posterUrl: '/files/locked-first-frame.jpg', + thumbnailsUrl: '/files/locked-thumbnails.vtt', ), Episode( id: 'locked-demo-2', diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 55c4a2b..3b7b661 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -393,6 +393,35 @@ void main() { expect(logic.sessionEpisodes.first.videoUrl, 'file:///local/signal-1.mp4'); }); + test('should_write_ticket_poster_url_into_session_episode', () async { + final logic = _logic( + FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'signal-1', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/signal-1.mp4', + posterUrl: '/files/transcoded/signal-1/poster.jpg', + ), + ), + ); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: _RecordingFactory().create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + expect( + logic.sessionEpisodes.first.posterUrl, + '/files/transcoded/signal-1/poster.jpg', + ); + + logic.dispose(); + await _drain(logic); + }); + group('surface flags', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index 294e2d2..587b0e4 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -17,6 +17,53 @@ sprite_001.jpg#xywh=0,0,160,90 '''; void main() { + test('should_choose_poster_before_thumbnails_when_both_are_available', () { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + + final frame = + playerOpeningFrame( + 'https://cdn.test/thumbnails.vtt', + posterUrl: 'https://cdn.test/poster.jpg', + spriteSource: service, + fit: BoxFit.cover, + ) + as PlayerOpeningFrame; + + expect(frame.posterUrl, 'https://cdn.test/poster.jpg'); + expect(frame.thumbnailsUrl, 'https://cdn.test/thumbnails.vtt'); + expect(frame.fit, BoxFit.cover); + expect( + playerOpeningFallback( + posterUrl: frame.posterUrl, + thumbnailsUrl: frame.thumbnailsUrl, + ), + PlayerOpeningFallback.poster, + ); + }); + + testWidgets('should_use_poster_without_decoding_sprite_when_poster_exists', ( + tester, + ) async { + final loader = _FakeSpriteLoader(); + final service = SpriteThumbnailAxisService(loader: loader); + addTearDown(service.dispose); + + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame( + posterUrl: 'https://cdn.test/poster.jpg', + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + spriteSource: service, + ), + ), + ); + + expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); + expect(find.byKey(const Key('player-opening-sprite')), findsNothing); + expect(loader.vttCalls, 0); + }); + testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', ( tester, ) async { From 80c962a95ce6f84e5cf4293d8efa0a9e80834a8b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 15:10:02 +0800 Subject: [PATCH 10/42] =?UTF-8?q?fix(player):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=8E=E5=BF=AB=E9=80=9F=E5=88=87=E9=9B=86?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/playback_session.dart | 2 + .../data/services/sprite_thumbnail_axis.dart | 6 +- lib/core/widgets/remote_image.dart | 75 ++++++++++++++++--- lib/pages/player/player_logic.dart | 34 +++++++++ lib/pages/player/player_view.dart | 24 +++++- .../player/widgets/player_opening_frame.dart | 26 +++++-- test/playback/sprite_thumbnail_axis_test.dart | 41 +++++++--- test/player/player_logic_test.dart | 36 +++++++++ test/player/player_opening_frame_test.dart | 32 ++++++++ test/player/remote_image_decode_test.dart | 35 +++++++++ 10 files changed, 281 insertions(+), 30 deletions(-) create mode 100644 test/player/remote_image_decode_test.dart diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index e3168fd..43727cb 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -81,6 +81,8 @@ class PlaybackSession extends ChangeNotifier { // Includes initialization and asynchronous disposal, until native release ends. int get livePlayerCount => _live.length; int? get preloadedIndex => _next?.prepared == true ? _next?.index : null; + int? get previousPreloadedIndex => + _previous?.prepared == true ? _previous?.index : null; Future get playbackSettled => _operations; Future get preloadSettled => _preloadTask; diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart index e6f7080..b8a4270 100644 --- a/lib/core/data/services/sprite_thumbnail_axis.dart +++ b/lib/core/data/services/sprite_thumbnail_axis.dart @@ -9,7 +9,7 @@ import '../../config/app_config.dart'; import 'sprite_thumbnail_asset_loader.dart'; const Duration spriteVttCacheTtl = Duration(minutes: 10); -const int spriteImageCacheCapacity = 2; +const int spriteImageCacheCapacity = 1; /// 单个 VTT cue 对应的 sprite 裁剪区域。 class SpriteThumbnailCue { @@ -148,7 +148,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier final Set _failedSprites = {}; bool _disposed = false; - @visibleForTesting int get spriteCacheSize => _spriteCache.length; @visibleForTesting @@ -156,7 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier .map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url) .toList(); - /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 + /// 播放器就绪时预取当前 cue;容量大于 1 时顺带预取相邻 sprite。 @override Future prepareAt(String? thumbnailsUrl, Duration position) async { final url = _normalizeUrl(thumbnailsUrl); @@ -269,6 +268,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier if (current == null) return const []; final index = axis.cues.indexOf(current); final candidates = [current]; + if (spriteCacheCapacity <= 1) return candidates; for (final direction in const [1, -1]) { for (var step = 1; step < axis.cues.length; step++) { final neighborIndex = index + direction * step; diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 967c8e1..4462959 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -5,6 +5,24 @@ import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../theme/app_colors.dart'; +const int _minImageDecodePixels = 96; +const int _maxImageDecodePixels = 2048; + +/// 将布局逻辑尺寸换算成有限像素尺寸,兼顾小头像和全屏首帧。 +int resolveImageDecodePixels(double? logicalExtent, double devicePixelRatio) { + final ratio = devicePixelRatio.isFinite && devicePixelRatio > 0 + ? devicePixelRatio + : 1.0; + final extent = + logicalExtent != null && logicalExtent.isFinite && logicalExtent > 0 + ? logicalExtent + : 120.0; + return (extent * ratio) + .ceil() + .clamp(_minImageDecodePixels, _maxImageDecodePixels) + .toInt(); +} + /// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。 class RemoteImage extends StatelessWidget { const RemoteImage( @@ -17,6 +35,8 @@ class RemoteImage extends StatelessWidget { this.fallbackIconKey, this.fallbackAspectRatio, this.fallbackColor = AppColors.raised, + this.decodeWidth, + this.decodeHeight, }); final String path; @@ -27,21 +47,56 @@ class RemoteImage extends StatelessWidget { final Key? fallbackIconKey; final double? fallbackAspectRatio; final Color fallbackColor; + final int? decodeWidth; + final int? decodeHeight; @override Widget build(BuildContext context) { final url = AppConfig.mediaUrl(path); if (url.isEmpty) return _fallback(brand: showFallbackIcon); - return Image.network( - url, - width: width, - height: height, - fit: fit, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return _fallback(brand: showFallbackIcon); + return LayoutBuilder( + builder: (context, constraints) { + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + final layoutWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : width; + final aspectRatio = + fallbackAspectRatio != null && + fallbackAspectRatio!.isFinite && + fallbackAspectRatio! > 0 + ? fallbackAspectRatio + : null; + final inferredHeight = constraints.hasBoundedHeight + ? constraints.maxHeight + : height ?? + (layoutWidth != null && aspectRatio != null + ? layoutWidth / aspectRatio + : null); + final cacheWidth = + decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio); + final cacheHeight = + decodeHeight ?? + (inferredHeight == null + ? null + : resolveImageDecodePixels(inferredHeight, pixelRatio)); + return Image.network( + url, + width: width, + height: height, + fit: fit, + cacheWidth: cacheWidth + .clamp(_minImageDecodePixels, _maxImageDecodePixels) + .toInt(), + cacheHeight: cacheHeight + ?.clamp(_minImageDecodePixels, _maxImageDecodePixels) + .toInt(), + gaplessPlayback: true, + errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return _fallback(brand: showFallbackIcon); + }, + ); }, ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index fa56e6f..b474a6b 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -93,6 +93,9 @@ class PlayerLogic extends ChangeNotifier { String selectedQuality = 'AUTO'; Duration? _qualitySeek; int pageRequest = 0; + int? _pendingSelectionIndex; + int? _pendingSelectionRevision; + Future? _selectionDrain; bool muted = false; double playbackSpeed = 1.0; bool stoppedAtFeedEnd = false; @@ -957,6 +960,37 @@ class PlayerLogic extends ChangeNotifier { ); } + /// 翻页入口使用 single-flight:执行中的切集不被重复排队,只保留最新目标。 + Future requestEpisodeSelection(int index, {int? revision}) { + if (_disposed || index < 0 || index >= sessionEpisodes.length) { + return Future.value(); + } + _pendingSelectionIndex = index; + _pendingSelectionRevision = revision; + return _selectionDrain ??= _drainEpisodeSelections(); + } + + Future _drainEpisodeSelections() async { + try { + while (!_disposed) { + final index = _pendingSelectionIndex; + if (index == null) break; + final revision = _pendingSelectionRevision; + _pendingSelectionIndex = null; + _pendingSelectionRevision = null; + await selectEpisode(index, revision: revision); + } + } finally { + _selectionDrain = null; + final index = _pendingSelectionIndex; + if (!_disposed && index != null) { + unawaited( + requestEpisodeSelection(index, revision: _pendingSelectionRevision), + ); + } + } + } + Future selectEpisode(int index, {int? revision}) async { final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 8a001ce..4e8c146 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -353,6 +353,28 @@ class _PlayerPageState extends State ); } + Future _selectPage(int index) async { + await logic.requestEpisodeSelection(index); + if (!mounted || logic.session.currentIndex != index) return; + await logic.session.preloadSettled; + if (!mounted || logic.session.currentIndex != index) return; + _logResourceWatermark(); + } + + void _logResourceWatermark() { + if (!kDebugMode) return; + final imageCache = PaintingBinding.instance.imageCache; + debugPrint( + '[player-resources] ' + 'slots=${logic.session.livePlayerCount} ' + 'warmNext=${logic.session.preloadedIndex ?? '-'} ' + 'warmPrevious=${logic.session.previousPreloadedIndex ?? '-'} ' + 'sprites=${_spriteAxisService.spriteCacheSize} ' + 'imageCacheBytes=${imageCache.currentSizeBytes} ' + 'imageCacheCount=${imageCache.currentSize}', + ); + } + void _setScrubbing(bool value) { if (!mounted) return; if (_scrubbing == value) return; @@ -507,7 +529,7 @@ class _PlayerPageState extends State onPageChanged: (position) { final index = indices[position]; widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(logic.selectEpisode(index)); + unawaited(_selectPage(index)); if (position >= indices.length - 3) { final loadMore = widget.onLoadMore; if (loadMore != null) unawaited(loadMore()); diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index b685c64..2841056 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import '../../../core/config/app_config.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; enum PlayerOpeningFallback { handoff, poster, thumbnails, black } @@ -149,12 +150,25 @@ class _PlayerOpeningFrameState extends State { children: [ const ColoredBox(color: AppColors.mediaBlack), if (_hasPoster) - Image.network( - AppConfig.mediaUrl(widget.posterUrl!.trim()), - key: const Key('player-opening-poster'), - fit: widget.fit, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _handlePosterError(), + LayoutBuilder( + builder: (context, constraints) { + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + return Image.network( + AppConfig.mediaUrl(widget.posterUrl!.trim()), + key: const Key('player-opening-poster'), + fit: widget.fit, + cacheWidth: resolveImageDecodePixels( + constraints.maxWidth, + pixelRatio, + ), + cacheHeight: resolveImageDecodePixels( + constraints.maxHeight, + pixelRatio, + ), + gaplessPlayback: true, + errorBuilder: (_, _, _) => _handlePosterError(), + ); + }, ) else _spriteOrBlack(), diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart index 90cba24..60cd045 100644 --- a/test/playback/sprite_thumbnail_axis_test.dart +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -75,7 +75,9 @@ void main() { expect(axis.cues, isEmpty); }); - testWidgets('should_keep_only_two_decoded_sprites_in_lru', (tester) async { + testWidgets('should_keep_one_sprite_and_reload_after_eviction', ( + tester, + ) async { final loader = _FakeSpriteLoader(vtt: _spriteVtt); final service = SpriteThumbnailAxisService( loader: loader, @@ -84,20 +86,37 @@ void main() { addTearDown(service.dispose); await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); - expect(service.spriteCacheSize, 2); - expect( - service.spriteCacheKeys, - containsAll(['sprite_001.jpg', 'sprite_002.jpg']), - ); - await service.prepareAt( 'https://cdn.test/thumbnails.vtt', const Duration(milliseconds: 2500), ); + await service.prepareAt( + 'https://cdn.test/thumbnails.vtt', + const Duration(milliseconds: 3500), + ); - expect(service.spriteCacheSize, 2); - expect(service.spriteCacheKeys, contains('sprite_002.jpg')); - expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg'))); + expect(service.spriteCacheSize, 1); + expect(service.spriteCacheKeys, ['sprite_003.jpg']); + expect(loader.images[0].debugDisposed, isTrue); + expect(loader.images[1].debugDisposed, isTrue); + expect(loader.images[2].debugDisposed, isFalse); + expect( + service.cachedFrameAt( + 'https://cdn.test/thumbnails.vtt', + const Duration(milliseconds: 3500), + ), + isNotNull, + ); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + expect(service.spriteCacheSize, 1); + expect(service.spriteCacheKeys, ['sprite_001.jpg']); + expect(loader.images[2].debugDisposed, isTrue); + expect(loader.images[3].debugDisposed, isFalse); + expect( + service.cachedFrameAt('https://cdn.test/thumbnails.vtt', Duration.zero), + isNotNull, + ); }); testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async { @@ -229,6 +248,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { final List? vttResponses; int vttCalls = 0; final List spriteCalls = []; + final List images = []; @override Future loadVtt(Uri uri) async { @@ -244,6 +264,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { Future loadSprite(Uri uri) async { spriteCalls.add(uri.pathSegments.last); final image = await _createImage(320, 180); + images.add(image); return SpriteThumbnailDecodedImage( image: image, originalWidth: 320, diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 3b7b661..aa7a3f2 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -422,6 +422,42 @@ void main() { await _drain(logic); }); + test('should_coalesce_rapid_page_selection_and_keep_pool_bounded', () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(); + final logic = _logic(FakeWatchHistory(), playback: playback); + logic.attach( + PlayerPage( + drama: _drama('rapid', numbers: const [1, 2, 3, 4, 5, 6]), + initialIndex: 0, + ), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + await Future.wait([ + for (var index = 1; index <= 5; index++) + logic.requestEpisodeSelection(index), + ]); + await _drain(logic); + + expect(logic.session.currentIndex, 5); + expect(playback.requests, containsAll(['rapid-1', 'rapid-2', 'rapid-6'])); + expect(playback.requests, isNot(contains('rapid-3'))); + expect(playback.requests, isNot(contains('rapid-4'))); + expect(playback.requests, isNot(contains('rapid-5'))); + expect(logic.session.livePlayerCount, lessThanOrEqualTo(3)); + expect(factory.live, lessThanOrEqualTo(3)); + + await logic.requestEpisodeSelection(0); + await _drain(logic); + expect(logic.session.currentIndex, 0); + + logic.dispose(); + await _drain(logic); + }); + group('surface flags', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index 587b0e4..c0a33c5 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -64,6 +64,38 @@ void main() { expect(loader.vttCalls, 0); }); + testWidgets('should_decode_fullscreen_poster_at_screen_width', ( + tester, + ) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 3), + child: Center( + child: SizedBox( + width: 390, + height: 844, + child: PlayerOpeningFrame( + posterUrl: 'https://cdn.test/poster.jpg', + thumbnailsUrl: '', + spriteSource: service, + ), + ), + ), + ), + ), + ); + + final image = tester.widget( + find.byKey(const Key('player-opening-poster')), + ); + final provider = image.image as ResizeImage; + expect(provider.width, 1170); + }); + testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', ( tester, ) async { diff --git a/test/player/remote_image_decode_test.dart b/test/player/remote_image_decode_test.dart new file mode 100644 index 0000000..0cd7de0 --- /dev/null +++ b/test/player/remote_image_decode_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/widgets/remote_image.dart'; + +void main() { + test('should_bound_layout_pixels_for_small_and_large_extents', () { + expect(resolveImageDecodePixels(12, 3), 96); + expect(resolveImageDecodePixels(120, 3), 360); + expect(resolveImageDecodePixels(4000, 3), 2048); + }); + + testWidgets('should_decode_grid_cover_at_layout_width_times_dpr', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 3), + child: Center( + child: SizedBox( + width: 120, + height: 160, + child: RemoteImage('/files/grid-cover.jpg'), + ), + ), + ), + ), + ); + + final image = tester.widget(find.byType(Image)); + final provider = image.image as ResizeImage; + expect(provider.width, 360); + expect(provider.height, 480); + }); +} From 5dafc6877534cc94afa1390db4eb2eabd201ab50 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 15:20:45 +0800 Subject: [PATCH 11/42] =?UTF-8?q?test(player):=20=E8=A6=86=E7=9B=96=20feed?= =?UTF-8?q?=20=E9=AB=98=E6=B8=85=E9=A6=96=E5=B8=A7=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/playback/playback_queue_test.dart | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/playback/playback_queue_test.dart b/test/playback/playback_queue_test.dart index d1dce8d..c11e2a4 100644 --- a/test/playback/playback_queue_test.dart +++ b/test/playback/playback_queue_test.dart @@ -51,12 +51,40 @@ void main() { 'title': '第 1 集', 'isFree': true, 'playUrl': '/files/episode/9.mp4', + 'posterUrl': '/files/transcoded/9/poster.jpg', + 'thumbnailsUrl': '/files/transcoded/9/thumbnails/thumbnails.vtt', }, }), ]); expect(home.length, 1); expect(home.entryAt(0).episode.videoUrl, '/files/episode/9.mp4'); + expect( + home.entryAt(0).episode.posterUrl, + '/files/transcoded/9/poster.jpg', + ); + }); + + test('should_parse_poster_url_for_drama_detail_episode_list', () { + final drama = Drama.fromDetailJson(const { + 'id': 5, + 'title': '闪婚后', + 'cover': '/files/cover.jpg', + 'episodes': [ + { + 'id': 9, + 'number': 1, + 'title': '第 1 集', + 'isFree': true, + 'posterUrl': '/files/transcoded/9/poster.jpg', + }, + ], + }); + + expect( + drama.episodes.single.posterUrl, + '/files/transcoded/9/poster.jpg', + ); }); } From 2a32dec1838b065217ab00f1b6cb2c315b5b3389 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 15:34:24 +0800 Subject: [PATCH 12/42] =?UTF-8?q?fix(player):=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E7=AB=96=E6=BB=91=E6=92=AD=E6=94=BE=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 270 ++++++++++++------ .../player/widgets/player_render_stack.dart | 203 +++++++++++++ .../player/widgets/player_video_surface.dart | 60 +++- test/player/player_fullscreen_test.dart | 13 + test/player/player_render_stack_test.dart | 148 ++++++++++ 5 files changed, 593 insertions(+), 101 deletions(-) create mode 100644 lib/pages/player/widgets/player_render_stack.dart create mode 100644 test/player/player_render_stack_test.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4e8c146..41b8871 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -40,6 +40,7 @@ import 'widgets/player_center_toggle.dart'; import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; +import 'widgets/player_render_stack.dart'; import 'widgets/player_speed_sheet.dart'; import 'widgets/player_unlock_sheet.dart'; import 'widgets/player_opening_frame.dart'; @@ -528,6 +529,14 @@ class _PlayerPageState extends State itemCount: indices.length, onPageChanged: (position) { final index = indices[position]; + if (kDebugMode) { + debugPrint( + '[player-page] ' + 'ts=${DateTime.now().millisecondsSinceEpoch} ' + 'phase=session-index ' + 'previous=${logic.session.currentIndex} next=$index', + ); + } widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); unawaited(_selectPage(index)); if (position >= indices.length - 3) { @@ -535,16 +544,37 @@ class _PlayerPageState extends State if (loadMore != null) unawaited(loadMore()); } }, - itemBuilder: (context, position) => - _buildEpisode(context, indices[position], interactions), + itemBuilder: (context, position) { + final index = indices[position]; + if (kDebugMode) { + debugPrint( + '[player-page] ' + 'ts=${DateTime.now().millisecondsSinceEpoch} ' + 'phase=item-build page=$position index=$index', + ); + } + return _buildEpisode(context, index, interactions); + }, ), ); - if (widget.onRefreshFeed == null) return pages; - return FeedPullRefresh( - pageController: logic.pages, - topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap, - onRefresh: _refreshHomeFeed, - child: pages, + final pageLayer = widget.onRefreshFeed == null + ? pages + : FeedPullRefresh( + pageController: logic.pages, + topInset: + MediaQuery.paddingOf(context).top + _feedRefreshTopGap, + onRefresh: _refreshHomeFeed, + child: pages, + ); + return Stack( + fit: StackFit.expand, + children: [ + PlayerRenderStack( + pageController: logic.pages, + slots: _renderSlots(indices), + ), + pageLayer, + ], ); }, ); @@ -555,7 +585,6 @@ class _PlayerPageState extends State if (widget.homeMode && widget.showHomeFeedHeader && !_scrubbing && - _chromeVisible && !_boosting && !logic.pip && !logic.pipPreparing) @@ -616,6 +645,145 @@ class _PlayerPageState extends State _openMore(context, entry.episode); } + Episode _resolvedEpisode(int index) { + final entry = logic.queue.entryAt(index); + final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length + ? logic.sessionEpisodes[index] + : entry.episode; + final displayed = widget.homeMode + ? logic.displayEpisode(entry.drama, sessionEpisode) + : sessionEpisode; + final needsSessionThumbnails = + (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && + sessionEpisode.thumbnailsUrl != null; + final needsSessionPoster = + (displayed.posterUrl == null || displayed.posterUrl!.isEmpty) && + sessionEpisode.posterUrl != null; + if (!needsSessionThumbnails && !needsSessionPoster) return displayed; + return displayed.withVideoUrl( + displayed.videoUrl, + thumbnailsUrl: sessionEpisode.thumbnailsUrl, + posterUrl: sessionEpisode.posterUrl, + ); + } + + Map _renderSlots( + List indices, + ) { + final current = logic.session.currentIndex; + final previous = + logic.previousVisibleIndex(current) ?? + logic.session.previousPreloadedIndex; + final next = + logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex; + return { + PlayerRenderSlotRole.previous: _renderSlot( + PlayerRenderSlotRole.previous, + previous, + indices, + currentIndex: current, + ), + PlayerRenderSlotRole.current: _renderSlot( + PlayerRenderSlotRole.current, + current, + indices, + currentIndex: current, + ), + PlayerRenderSlotRole.next: _renderSlot( + PlayerRenderSlotRole.next, + next, + indices, + currentIndex: current, + ), + }; + } + + PlayerRenderSlotData _renderSlot( + PlayerRenderSlotRole role, + int? index, + List indices, { + required int currentIndex, + }) { + if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { + return PlayerRenderSlotData.empty(role); + } + final episode = _resolvedEpisode(index); + final selected = index == currentIndex; + final locked = + logic.isEpisodeLocked(episode) || + (selected && logic.session.isLocked) || + logic.blockedEpisodeId == episode.id; + final port = locked + ? null + : (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final handoff = selected && !locked ? logic.session.handoffAt(index) : null; + final handoffPort = handoff is VideoPlayerPort ? handoff : null; + final videoReady = videoPort?.controller.value.isInitialized ?? false; + final theater = logic.pip || logic.pipPreparing; + final videoFit = theater ? BoxFit.contain : BoxFit.cover; + final preparedFrameAvailable = + !locked && + videoReady && + (selected + ? logic.session.reusedWarmAt(index) + : logic.session.preparedPlayerAt(index) != null); + final thumbnailsUrl = episode.thumbnailsUrl; + final posterUrl = episode.posterUrl; + final posterReady = posterUrl?.trim().isNotEmpty ?? false; + final thumbnailReady = + thumbnailsUrl != null && + _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; + if (!locked && !posterReady && thumbnailsUrl != null) { + unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); + } + if (selected && + !locked && + videoPort != null && + videoPort.controller.value.isInitialized) { + unawaited( + _spriteAxisService.prepareAt( + thumbnailsUrl, + videoPort.controller.value.position, + ), + ); + } + final pagePosition = indices.indexOf(index); + return PlayerRenderSlotData( + role: role, + index: index, + pagePosition: pagePosition >= 0 ? pagePosition.toDouble() : null, + controller: locked ? null : videoPort?.controller, + videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, + theater: theater, + warmFrameAvailable: preparedFrameAvailable, + thumbnailReady: thumbnailReady, + posterReady: posterReady, + fallback: handoffPort != null + ? PlayerOpeningFallback.handoff + : playerOpeningFallback( + posterUrl: posterUrl, + thumbnailsUrl: thumbnailsUrl, + ), + warmSlotReused: logic.session.reusedWarmAt(index), + waitForPlaybackFrame: logic.session.userWantsPlay, + placeholder: locked + ? null + : handoffPort != null + ? ControllerFramePlaceholder( + controller: handoffPort.controller, + theater: theater, + ) + : playerOpeningFrame( + thumbnailsUrl, + posterUrl: posterUrl, + spriteSource: _spriteAxisService, + fit: videoFit, + ), + ); + } + Widget _buildEpisode( BuildContext context, int index, @@ -624,25 +792,7 @@ class _PlayerPageState extends State final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); final drama = entry.drama; - final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length - ? logic.sessionEpisodes[index] - : entry.episode; - final displayed = widget.homeMode - ? logic.displayEpisode(drama, sessionEpisode) - : sessionEpisode; - final needsSessionThumbnails = - (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && - sessionEpisode.thumbnailsUrl != null; - final needsSessionPoster = - (displayed.posterUrl == null || displayed.posterUrl!.isEmpty) && - sessionEpisode.posterUrl != null; - final episode = needsSessionThumbnails || needsSessionPoster - ? displayed.withVideoUrl( - displayed.videoUrl, - thumbnailsUrl: sessionEpisode.thumbnailsUrl, - posterUrl: sessionEpisode.posterUrl, - ) - : displayed; + final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; final locked = logic.isEpisodeLocked(episode) || @@ -655,79 +805,15 @@ class _PlayerPageState extends State : (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; - final handoff = selected && !locked ? logic.session.handoffAt(index) : null; - final handoffPort = handoff is VideoPlayerPort ? handoff : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; if (selected && videoReady) _logSwipeReveal(); final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; - final videoFit = theater ? BoxFit.contain : BoxFit.cover; - final preparedFrameAvailable = - !locked && - videoReady && - (selected - ? logic.session.reusedWarmAt(index) - : logic.session.preparedPlayerAt(index) != null); - final warmSlotReused = logic.session.reusedWarmAt(index); - final thumbnailsUrl = episode.thumbnailsUrl; - final posterUrl = episode.posterUrl; - final posterReady = posterUrl?.trim().isNotEmpty ?? false; - final openingFrameReady = - thumbnailsUrl != null && - _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; - if (!posterReady && thumbnailsUrl != null) { - unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); - } - if (selected && - !locked && - videoPort != null && - videoPort.controller.value.isInitialized) { - unawaited( - _spriteAxisService.prepareAt( - episode.thumbnailsUrl, - videoPort.controller.value.position, - ), - ); - } return Stack( fit: StackFit.expand, children: [ - if (widget.homeMode) - const ColoredBox( - key: Key('home-feed-black-background'), - color: AppColors.mediaBlack, - ), - PlayerVideoSurface( - controller: locked ? null : videoPort?.controller, - videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, - theater: theater, - warmFrameAvailable: preparedFrameAvailable, - warmSlotReused: warmSlotReused, - thumbnailReady: openingFrameReady, - posterReady: posterReady, - fallback: handoffPort != null - ? PlayerOpeningFallback.handoff - : playerOpeningFallback( - posterUrl: posterUrl, - thumbnailsUrl: thumbnailsUrl, - ), - waitForPlaybackFrame: logic.session.userWantsPlay, - placeholder: locked - ? null - : handoffPort != null - ? ControllerFramePlaceholder( - controller: handoffPort.controller, - theater: theater, - ) - : playerOpeningFrame( - thumbnailsUrl, - posterUrl: posterUrl, - spriteSource: _spriteAxisService, - fit: videoFit, - ), - ), if (selected && !locked && !theater && diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart new file mode 100644 index 0000000..2f4dc29 --- /dev/null +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -0,0 +1,203 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/theme/app_colors.dart'; +import 'player_opening_frame.dart'; +import 'player_video_surface.dart'; + +enum PlayerRenderSlotRole { previous, current, next } + +class PlayerRenderSlotData { + const PlayerRenderSlotData({ + required this.role, + this.index, + this.pagePosition, + this.controller, + this.videoUrl = '', + this.theater = false, + this.placeholder, + this.warmFrameAvailable = false, + this.thumbnailReady = false, + this.posterReady = false, + this.fallback = PlayerOpeningFallback.black, + this.warmSlotReused = false, + this.waitForPlaybackFrame = false, + }); + + final PlayerRenderSlotRole role; + final int? index; + final double? pagePosition; + final VideoPlayerController? controller; + final String videoUrl; + final bool theater; + final Widget? placeholder; + final bool warmFrameAvailable; + final bool thumbnailReady; + final bool posterReady; + final PlayerOpeningFallback fallback; + final bool warmSlotReused; + final bool waitForPlaybackFrame; + + factory PlayerRenderSlotData.empty(PlayerRenderSlotRole role) { + return PlayerRenderSlotData(role: role); + } +} + +/// 播放画面不随 PageView 生命周期销毁,三个固定槽位只更新控制器和位置。 +class PlayerRenderStack extends StatefulWidget { + const PlayerRenderStack({ + super.key, + required this.pageController, + required this.slots, + }); + + final PageController pageController; + final Map slots; + + @override + State createState() => _PlayerRenderStackState(); +} + +class _PlayerRenderStackState extends State { + int? _lastCurrentIndex; + + @override + void initState() { + super.initState(); + _lastCurrentIndex = _currentSlot.index; + _log('init current=${_lastCurrentIndex ?? '-'}'); + } + + @override + void didUpdateWidget(covariant PlayerRenderStack oldWidget) { + super.didUpdateWidget(oldWidget); + final currentIndex = _currentSlot.index; + if (_lastCurrentIndex != currentIndex) { + _log( + 'session-index previous=${_lastCurrentIndex ?? '-'} ' + 'current=${currentIndex ?? '-'}', + ); + _lastCurrentIndex = currentIndex; + } + } + + @override + void dispose() { + _log('dispose current=${_lastCurrentIndex ?? '-'}'); + super.dispose(); + } + + PlayerRenderSlotData get _currentSlot => + widget.slots[PlayerRenderSlotRole.current] ?? + PlayerRenderSlotData.empty(PlayerRenderSlotRole.current); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedBuilder( + animation: widget.pageController, + builder: (context, _) { + return LayoutBuilder( + builder: (context, constraints) { + final viewportHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : MediaQuery.sizeOf(context).height; + final pageOffset = _pageOffset(); + final currentPage = _currentSlot.pagePosition ?? pageOffset; + final slots = [ + _slotData(PlayerRenderSlotRole.previous), + _slotData(PlayerRenderSlotRole.current), + _slotData(PlayerRenderSlotRole.next), + ]; + return Stack( + key: const Key('player-render-stack'), + fit: StackFit.expand, + clipBehavior: Clip.hardEdge, + children: [ + const ColoredBox( + key: Key('player-fallback-background'), + color: AppColors.mediaBlack, + ), + for (final slot in slots) + _positionedSurface( + slot, + pageOffset: pageOffset, + viewportHeight: viewportHeight, + fallbackPage: _fallbackPage(slot.role, currentPage), + ), + ], + ); + }, + ); + }, + ), + ); + } + + PlayerRenderSlotData _slotData(PlayerRenderSlotRole role) { + return widget.slots[role] ?? PlayerRenderSlotData.empty(role); + } + + double _pageOffset() { + if (!widget.pageController.hasClients) { + return widget.pageController.initialPage.toDouble(); + } + final position = widget.pageController.position; + if (!position.hasPixels || + !position.hasViewportDimension || + position.viewportDimension <= 0) { + return widget.pageController.initialPage.toDouble(); + } + return position.pixels / position.viewportDimension; + } + + double _fallbackPage(PlayerRenderSlotRole role, double currentPage) { + return switch (role) { + PlayerRenderSlotRole.previous => currentPage - 1, + PlayerRenderSlotRole.current => currentPage, + PlayerRenderSlotRole.next => currentPage + 1, + }; + } + + Widget _positionedSurface( + PlayerRenderSlotData slot, { + required double pageOffset, + required double viewportHeight, + required double fallbackPage, + }) { + final slotPage = slot.pagePosition ?? fallbackPage; + final offset = (slotPage - pageOffset) * viewportHeight; + return Positioned.fill( + child: Transform.translate( + key: Key('player-render-transform-${slot.role.name}'), + offset: Offset(0, offset), + child: PlayerVideoSurface( + key: ValueKey('player-render-slot-${slot.role.name}'), + debugSlot: slot.role.name, + debugIndex: slot.index, + showFallbackBackground: false, + controller: slot.controller, + videoUrl: slot.videoUrl, + theater: slot.theater, + placeholder: slot.placeholder, + warmFrameAvailable: slot.warmFrameAvailable, + thumbnailReady: slot.thumbnailReady, + posterReady: slot.posterReady, + fallback: slot.fallback, + warmSlotReused: slot.warmSlotReused, + waitForPlaybackFrame: slot.waitForPlaybackFrame, + ), + ), + ); + } + + void _log(String message) { + if (!kDebugMode) return; + debugPrint( + '[player-render] ts=${DateTime.now().millisecondsSinceEpoch} $message', + ); + } +} diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index ec4ca86..87884a7 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -25,6 +25,9 @@ class PlayerVideoSurface extends StatefulWidget { this.fallback = PlayerOpeningFallback.black, this.warmSlotReused = false, this.waitForPlaybackFrame = false, + this.debugSlot = 'default', + this.debugIndex, + this.showFallbackBackground = true, }); final VideoPlayerController? controller; @@ -49,6 +52,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 自动播放中的新集等待 position 首次前进后再移交画面。 final bool waitForPlaybackFrame; + final String debugSlot; + final int? debugIndex; + final bool showFallbackBackground; final String videoUrl; final bool theater; @@ -79,8 +85,11 @@ class _PlayerVideoSurfaceState extends State final value = widget.controller?.value; _updatePlaybackFlags(value); _videoSize = value != null && value.isInitialized ? value.size : null; + _logSlot('init', value); if (_firstFrameConfirmed(value)) { _confirmFirstFrame(initial: true); + } else { + _logLayer('placeholder', value); } } @@ -88,13 +97,19 @@ class _PlayerVideoSurfaceState extends State void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { + _logSlot('controller-change-before', oldWidget.controller?.value); _detachController(oldWidget.controller); _resetForControllerChange(); _attachController(widget.controller); final value = widget.controller?.value; _updatePlaybackFlags(value); _videoSize = value != null && value.isInitialized ? value.size : null; - if (_firstFrameConfirmed(value)) _confirmFirstFrame(initial: true); + _logSlot('controller-change-after', value); + if (_firstFrameConfirmed(value)) { + _confirmFirstFrame(initial: true); + } else { + _logLayer('placeholder', value); + } return; } if (!_transitioning && @@ -119,6 +134,7 @@ class _PlayerVideoSurfaceState extends State @override void dispose() { + _logSlot('dispose', widget.controller?.value); _detachController(widget.controller); _crossFade ..removeStatusListener(_handleFadeStatus) @@ -143,10 +159,11 @@ class _PlayerVideoSurfaceState extends State return Stack( fit: StackFit.expand, children: [ - const ColoredBox( - key: Key('player-fallback-background'), - color: AppColors.mediaBlack, - ), + if (widget.showFallbackBackground) + const ColoredBox( + key: Key('player-fallback-background'), + color: AppColors.mediaBlack, + ), if (showPlaceholder) KeyedSubtree( key: const Key('player-single-placeholder'), @@ -210,14 +227,16 @@ class _PlayerVideoSurfaceState extends State _confirmationScheduled = true; if (kDebugMode) { debugPrint( - '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' 'phase=first-frame-scheduled', ); } WidgetsBinding.instance.addPostFrameCallback((_) { if (kDebugMode) { debugPrint( - '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' 'phase=first-frame-callback mounted=$mounted', ); } @@ -246,6 +265,7 @@ class _PlayerVideoSurfaceState extends State void _confirmFirstFrame({bool initial = false}) { if (_frameConfirmed || _videoSize == null) return; _frameConfirmed = true; + _logSlot('firstFrameRendered', widget.controller?.value); final canSkipPlaceholder = widget.warmFrameAvailable || _placeholderSnapshot == null; if (canSkipPlaceholder) { @@ -303,6 +323,8 @@ class _PlayerVideoSurfaceState extends State ? 'video' : _fallbackLabel(widget.fallback); final signature = + 'slot=${widget.debugSlot} ' + 'index=${widget.debugIndex ?? '-'} ' 'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} ' 'reusedWarm=${widget.warmSlotReused ? 1 : 0} ' 'frame=${_frameConfirmed ? 1 : 0} ' @@ -312,14 +334,34 @@ class _PlayerVideoSurfaceState extends State 'init=${value?.isInitialized == true ? 1 : 0} ' 'position=${value?.position.inMilliseconds ?? 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' - 'buffering=${value?.isBuffering == true ? 1 : 0}'; + 'buffering=${value?.isBuffering == true ? 1 : 0} ' + 'placeholder=${_placeholderLabel()}'; if (signature == _lastLayerState) return; _lastLayerState = signature; debugPrint( - '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature', + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} $signature', ); } + void _logSlot(String phase, VideoPlayerValue? value) { + if (!kDebugMode) return; + debugPrint( + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' + 'phase=$phase init=${value?.isInitialized == true ? 1 : 0}', + ); + } + + String _placeholderLabel() { + if (_placeholderSnapshot == null) return 'none'; + return switch (widget.fallback) { + PlayerOpeningFallback.handoff => 'handoff', + PlayerOpeningFallback.poster => 'poster', + PlayerOpeningFallback.thumbnails => 'thumbnails', + PlayerOpeningFallback.black => 'none', + }; + } + String _fallbackLabel(PlayerOpeningFallback fallback) { if (_placeholderSnapshot == null) return 'black'; return switch (fallback) { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7704bfc..68d6622 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -55,6 +55,19 @@ void main() { pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical), hasLength(1), ); + expect(find.byKey(const Key('player-render-stack')), findsOneWidget); + final renderStackPageAncestors = tester.widgetList( + find.ancestor( + of: find.byKey(const Key('player-render-stack')), + matching: find.byType(PageView), + ), + ); + expect( + renderStackPageAncestors.where( + (pageView) => pageView.scrollDirection == Axis.vertical, + ), + isEmpty, + ); expect(tester.takeException(), isNull); }); diff --git a/test/player/player_render_stack_test.dart b/test/player/player_render_stack_test.dart new file mode 100644 index 0000000..ddd7d0a --- /dev/null +++ b/test/player/player_render_stack_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart'; +import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + testWidgets('should_keep_slots_alive_and_only_translate_during_page_change', ( + tester, + ) async { + final pages = PageController(initialPage: 1); + final previousController = _controller('previous'); + final currentController = _controller('current'); + final nextController = _controller('next'); + addTearDown(pages.dispose); + addTearDown(previousController.dispose); + addTearDown(currentController.dispose); + addTearDown(nextController.dispose); + + var currentIndex = 1; + late StateSetter rebuild; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return Stack( + fit: StackFit.expand, + children: [ + PlayerRenderStack( + pageController: pages, + slots: _slots( + currentIndex: currentIndex, + previousController: previousController, + currentController: currentController, + nextController: nextController, + ), + ), + PageView( + controller: pages, + children: const [ + SizedBox.expand(key: Key('page-0')), + SizedBox.expand(key: Key('page-1')), + SizedBox.expand(key: Key('page-2')), + ], + ), + ], + ); + }, + ), + ), + ), + ); + await tester.pump(); + + final currentSlot = find.byKey( + const ValueKey('player-render-slot-current'), + ); + final currentState = tester.state(currentSlot); + final currentElement = tester.element(currentSlot); + final currentSurface = tester.widget(currentSlot); + expect(currentSurface.controller, same(currentController)); + expect(_translationY(tester, 'current'), 0); + expect(_translationY(tester, 'next'), greaterThan(0)); + + pages.jumpToPage(2); + await tester.pump(); + await tester.pump(); + + expect(tester.state(currentSlot), same(currentState)); + expect(tester.element(currentSlot), same(currentElement)); + expect( + tester.widget(currentSlot).controller, + same(currentController), + ); + expect(_translationY(tester, 'current'), lessThan(0)); + expect(_translationY(tester, 'next'), 0); + + rebuild(() => currentIndex = 2); + await tester.pump(); + + expect(tester.state(currentSlot), same(currentState)); + expect(tester.element(currentSlot), same(currentElement)); + expect(_translationY(tester, 'current'), 0); + expect(_translationY(tester, 'previous'), lessThan(0)); + expect( + find.descendant( + of: find.byType(PlayerRenderStack), + matching: find.byType(IgnorePointer), + ), + findsOneWidget, + ); + }); +} + +Map _slots({ + required int currentIndex, + required VideoPlayerController previousController, + required VideoPlayerController currentController, + required VideoPlayerController nextController, +}) { + return { + PlayerRenderSlotRole.previous: PlayerRenderSlotData( + role: PlayerRenderSlotRole.previous, + index: currentIndex - 1, + pagePosition: (currentIndex - 1).toDouble(), + controller: previousController, + placeholder: const ColoredBox( + key: Key('previous-placeholder'), + color: Colors.red, + ), + ), + PlayerRenderSlotRole.current: PlayerRenderSlotData( + role: PlayerRenderSlotRole.current, + index: currentIndex, + pagePosition: currentIndex.toDouble(), + controller: currentController, + placeholder: const ColoredBox( + key: Key('current-placeholder'), + color: Colors.green, + ), + ), + PlayerRenderSlotRole.next: PlayerRenderSlotData( + role: PlayerRenderSlotRole.next, + index: currentIndex + 1, + pagePosition: (currentIndex + 1).toDouble(), + controller: nextController, + placeholder: const ColoredBox( + key: Key('next-placeholder'), + color: Colors.blue, + ), + ), + }; +} + +VideoPlayerController _controller(String name) { + return VideoPlayerController.networkUrl( + Uri.parse('https://example.test/$name.mp4'), + ); +} + +double _translationY(WidgetTester tester, String role) { + final transform = tester.widget( + find.byKey(Key('player-render-transform-$role')), + ); + return transform.transform.getTranslation().y; +} From ab4c246675283c88b66ced357a83f41a910e220a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 15:44:28 +0800 Subject: [PATCH 13/42] =?UTF-8?q?fix(player):=20=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E9=A1=B5=E8=BF=94=E5=9B=9E=E6=97=B6=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/recommend/recommend_page.dart | 7 ++++- test/player/player_fullscreen_test.dart | 42 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 4f29b3a..d6f0967 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -46,11 +46,15 @@ class RecommendPage extends StatefulWidget { State createState() => _RecommendPageState(); } -class _RecommendPageState extends State { +class _RecommendPageState extends State + with AutomaticKeepAliveClientMixin { RecommendInnerTab _tab = RecommendInnerTab.feed; final PageController _pageController = PageController(); int _scrubCancelRequest = 0; + @override + bool get wantKeepAlive => true; + @override void initState() { super.initState(); @@ -85,6 +89,7 @@ class _RecommendPageState extends State { @override Widget build(BuildContext context) { + super.build(context); final categories = context.read(); return ColoredBox( key: const Key('recommend-page'), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 68d6622..34a1fc7 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -21,6 +21,7 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; +import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import '../support/fake_catalog.dart'; @@ -82,6 +83,47 @@ void main() { _expectPlayerNavigationBar(tester); }); + testWidgets('should_preserve_recommend_feed_when_full_player_pops', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + final recommendPlayer = find.byKey( + const Key('recommend-feed-player'), + ); + final renderStack = find.descendant( + of: recommendPlayer, + matching: find.byType(PlayerRenderStack), + ); + expect(recommendPlayer, findsOneWidget); + expect(renderStack, findsOneWidget); + final playerState = tester.state(recommendPlayer); + final renderState = tester.state(renderStack); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isFalse, + ); + + tester.state(find.byType(Navigator).first).pop(); + await tester.pump(); + + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(recommendPlayer, findsOneWidget); + expect(renderStack, findsOneWidget); + expect(tester.state(recommendPlayer), same(playerState)); + expect(tester.state(renderStack), same(renderState)); + expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); + + await tester.pumpAndSettle(); + expect(tester.state(recommendPlayer), same(playerState)); + expect(tester.state(renderStack), same(renderState)); + }); + testWidgets('should_render_player_loading_indicators_at_compact_size', ( tester, ) async { From 23e80dd637d9876329ba518587040cc5fbbb0a0b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 15:55:46 +0800 Subject: [PATCH 14/42] =?UTF-8?q?fix(player):=20=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E4=BC=9A=E8=AF=9D=E8=BF=9B=E5=85=A5=E5=85=A8?= =?UTF-8?q?=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 240 ++++++++++++++----- lib/pages/player/widgets/player_overlay.dart | 6 + test/player/player_fullscreen_test.dart | 118 ++++++++- test/recommend/recommend_sections_test.dart | 16 +- 4 files changed, 303 insertions(+), 77 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 41b8871..d11f033 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -128,6 +128,7 @@ class _PlayerPageState extends State DateTime? _lastDoubleTapAt; DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; + bool _fullPlayerPresented = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -257,11 +258,13 @@ class _PlayerPageState extends State @override void didPushNext() { + if (_fullPlayerPresented) return; logic.onRouteCovered(); } @override void didPopNext() { + if (_fullPlayerPresented) return; logic.onRouteRevealed(); } @@ -930,7 +933,7 @@ class _PlayerPageState extends State ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (widget.homeMode) + else if (widget.homeMode && !_fullPlayerPresented) HomeFeedOverlay( l10n: l10n, drama: drama, @@ -959,10 +962,8 @@ class _PlayerPageState extends State onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), - onOpenFullPlayer: () async { - await widget.onOpenFullPlayer?.call(drama, episode); - if (!mounted || !widget.active) return; - logic.session.setUserWantsPlay(true); + onOpenFullPlayer: () { + unawaited(_presentFullPlayer(context, index)); }, onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, @@ -978,64 +979,8 @@ class _PlayerPageState extends State thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, ) - else - PlayerPlaybackOverlay( - data: PlayerOverlayData( - l10n: l10n, - drama: drama, - episode: episode, - controller: videoReady ? videoPort!.controller : null, - locked: locked, - liked: interactions.isEpisodeLiked(episode.id), - likeCount: - interactions.episodeLikeCount(episode.id) ?? - episode.likeCount, - commentCount: _commentCounts[episode.id] ?? episode.commentCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - saved: context.watch().isSaved(drama.id), - playing: logic.session.userWantsPlay, - playbackSpeed: _playbackSpeed, - totalEpisodes: logic.visibleIndices.length, - boosting: _boosting, - thumbnailService: _thumbnailService, - spriteAxisService: _spriteAxisService, - ), - scrubbing: _scrubbing, - actions: PlayerOverlayActions( - onBack: widget.embedded - ? null - : () => unawaited(logic.leave(context)), - onTogglePlay: _togglePlayback, - onClear: () => setState(() => _chromeVisible = false), - onExitClear: () => setState(() => _chromeVisible = true), - onDetail: () => unawaited(logic.openDetail(context)), - onSpeed: () => unawaited(_openSpeedSheet(context)), - onEpisodes: () => logic.showEpisodes(context), - onComments: () => - unawaited(_openComments(context, drama, episode)), - onLike: () => - unawaited(_toggleLike(context, interactions, episode)), - onSave: () => unawaited(_toggleSave(context, drama)), - onShare: () => logic.shareDrama(context, drama), - onMore: () => _openMore(context, episode), - onScrubStart: videoReady - ? () { - _setScrubbing(true); - logic.beginScrub(videoPort!); - } - : null, - onScrubEnd: () => _setScrubbing(false), - onSeek: videoReady - ? (position) => logic.seekTo(context, videoPort!, position) - : null, - onLongPressCenter: () => _openMore(context, episode), - onBoostStart: _startBoost, - onBoostEnd: _endBoost, - ), - controlsVisible: _chromeVisible, - ), + else if (!widget.homeMode) + _standardPlaybackOverlay(context, index, interactions), // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 if (selected && !theater && locked) PlayerBlockedNotice( @@ -1075,4 +1020,173 @@ class _PlayerPageState extends State ], ); } + + Widget _standardPlaybackOverlay( + BuildContext context, + int index, + InteractionController interactions, { + bool presentation = false, + }) { + if (logic.pip || logic.pipPreparing) return const SizedBox.expand(); + final l10n = AppLocalizations.of(context)!; + final entry = logic.queue.entryAt(index); + final drama = entry.drama; + final episode = _resolvedEpisode(index); + final selected = index == logic.session.currentIndex; + final locked = + logic.isEpisodeLocked(episode) || + logic.session.isLocked || + logic.blockedEpisodeId == episode.id; + final port = locked + ? null + : (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final videoReady = videoPort?.controller.value.isInitialized ?? false; + return PlayerPlaybackOverlay( + data: PlayerOverlayData( + l10n: l10n, + drama: drama, + episode: episode, + controller: videoReady ? videoPort!.controller : null, + locked: locked, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: + interactions.episodeLikeCount(episode.id) ?? episode.likeCount, + commentCount: _commentCounts[episode.id] ?? episode.commentCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + saved: context.watch().isSaved(drama.id), + playing: logic.session.userWantsPlay, + playbackSpeed: _playbackSpeed, + totalEpisodes: logic.visibleIndices.length, + boosting: _boosting, + thumbnailService: _thumbnailService, + spriteAxisService: _spriteAxisService, + ), + scrubbing: _scrubbing, + actions: PlayerOverlayActions( + onBack: presentation + ? () => Navigator.of(context).pop() + : widget.embedded + ? null + : () => unawaited(logic.leave(context)), + onTogglePlay: _togglePlayback, + onClear: () => setState(() => _chromeVisible = false), + onExitClear: () => setState(() => _chromeVisible = true), + onDetail: () => unawaited(logic.openDetail(context)), + onSpeed: () => unawaited(_openSpeedSheet(context)), + onEpisodes: () => logic.showEpisodes(context), + onComments: () => unawaited(_openComments(context, drama, episode)), + onLike: () => unawaited(_toggleLike(context, interactions, episode)), + onSave: () => unawaited(_toggleSave(context, drama)), + onShare: () => logic.shareDrama(context, drama), + onMore: () => _openMore(context, episode), + onScrubStart: videoReady + ? () { + _setScrubbing(true); + logic.beginScrub(videoPort!); + } + : null, + onScrubEnd: () => _setScrubbing(false), + onSeek: videoReady + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + onLongPressCenter: () => _openMore(context, episode), + onBoostStart: _startBoost, + onBoostEnd: _endBoost, + ), + controlsVisible: _chromeVisible, + onVerticalDragUpdate: presentation + ? _handleFullPlayerDragUpdate + : null, + onVerticalDragEnd: presentation ? _handleFullPlayerDragEnd : null, + ); + } + + Future _presentFullPlayer(BuildContext context, int index) async { + if (_fullPlayerPresented || !logic.pages.hasClients) return; + // The route owns presentation only; PlaybackSession keeps the controller. + final interactions = context.read(); + _logFullPlayerRoute('present'); + setState(() => _fullPlayerPresented = true); + await Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: null, + barrierDismissible: false, + transitionDuration: const Duration(milliseconds: 180), + reverseTransitionDuration: const Duration(milliseconds: 160), + pageBuilder: (routeContext, _, _) => Material( + color: AppColors.transparent, + child: Scaffold( + backgroundColor: AppColors.transparent, + body: ListenableBuilder( + listenable: logic, + builder: (context, _) => AnimatedBuilder( + animation: logic.session, + builder: (context, _) { + final current = logic.session.currentIndex; + return _standardPlaybackOverlay( + routeContext, + current, + interactions, + presentation: true, + ); + }, + ), + ), + ), + ), + transitionsBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + if (!mounted) return; + setState(() => _fullPlayerPresented = false); + _logFullPlayerRoute('restored'); + } + + void _logFullPlayerRoute(String phase) { + if (!kDebugMode) return; + final active = logic.session.active; + final video = active is VideoPlayerPort ? active : null; + debugPrint( + '[player-route] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'phase=$phase ' + 'episode=${logic.session.currentEpisode.id} ' + 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' + 'position=${video?.controller.value.position.inMilliseconds ?? '-'}', + ); + } + + void _handleFullPlayerDragUpdate(DragUpdateDetails details) { + if (!logic.pages.hasClients) return; + final position = logic.pages.position; + final next = (position.pixels - details.delta.dy).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((next - position.pixels).abs() < 0.5) return; + position.jumpTo(next); + } + + void _handleFullPlayerDragEnd(DragEndDetails details) { + if (!logic.pages.hasClients || logic.visibleIndices.isEmpty) return; + final position = logic.pages.position; + final page = position.pixels / position.viewportDimension; + final velocity = details.primaryVelocity ?? 0; + var target = page.round(); + if (velocity < -260) target = page.floor() + 1; + if (velocity > 260) target = page.ceil() - 1; + target = target.clamp(0, logic.visibleIndices.length - 1); + unawaited( + logic.pages.animateToPage( + target, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + ), + ); + } } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 0a89365..a2ce47b 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -36,6 +36,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { required this.actions, required this.controlsVisible, required this.scrubbing, + this.onVerticalDragUpdate, + this.onVerticalDragEnd, }); /// 展示数据。 @@ -49,6 +51,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { /// 正在拖动进度条。拖动期间清屏,只留进度条。 final bool scrubbing; + final GestureDragUpdateCallback? onVerticalDragUpdate; + final GestureDragEndCallback? onVerticalDragEnd; @override Widget build(BuildContext context) { @@ -65,6 +69,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => actions.onBoostEnd(), onLongPressCancel: actions.onBoostEnd, + onVerticalDragUpdate: onVerticalDragUpdate, + onVerticalDragEnd: onVerticalDragEnd, ), ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 34a1fc7..0d79293 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -17,12 +17,14 @@ 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_session.dart'; +import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; +import 'package:video_player/video_player.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -86,7 +88,9 @@ void main() { testWidgets('should_preserve_recommend_feed_when_full_player_pops', ( tester, ) async { - await _pumpApp(tester); + final factory = _RouteVideoFactory(); + final playback = FakePlaybackRepository(); + await _pumpApp(tester, playerFactory: factory.create, playback: playback); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); @@ -101,27 +105,80 @@ void main() { expect(renderStack, findsOneWidget); final playerState = tester.state(recommendPlayer); final renderState = tester.state(renderStack); + final currentSurface = find.descendant( + of: recommendPlayer, + matching: find.byKey( + const ValueKey('player-render-slot-current'), + ), + ); + final controller = factory.created.first.controller; + final createdBefore = factory.created.length; + final requestsBefore = playback.requests.length; + expect(controller.value.position, const Duration(seconds: 5)); + expect( + find.descendant( + of: currentSurface, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, + ); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); + expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(factory.created.length, createdBefore); + expect(playback.requests.length, requestsBefore); + expect(controller.value.position, const Duration(seconds: 5)); expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, + find.descendant( + of: currentSurface, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, ); tester.state(find.byType(Navigator).first).pop(); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(recommendPlayer, findsOneWidget); expect(renderStack, findsOneWidget); + expect(factory.created.length, createdBefore); + expect(playback.requests.length, requestsBefore); + expect(controller.value.position, const Duration(seconds: 5)); expect(tester.state(recommendPlayer), same(playerState)); expect(tester.state(renderStack), same(renderState)); expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); + expect( + find.descendant( + of: currentSurface, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, + ); + }); + testWidgets('should_swipe_full_player_without_creating_another_session', ( + tester, + ) async { + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(tester.state(recommendPlayer), same(playerState)); - expect(tester.state(renderStack), same(renderState)); + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + final createdBefore = factory.created.length; + await tester.drag( + find.byKey(const Key('player-video-tap')), + const Offset(0, -560), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PlayerPage), findsOneWidget); + expect(factory.created.length, lessThanOrEqualTo(createdBefore + 1)); + expect(find.byKey(const Key('player-render-stack')), findsOneWidget); }); testWidgets('should_render_player_loading_indicators_at_compact_size', ( @@ -1482,6 +1539,55 @@ Future _pumpApp( if (settle) await tester.pumpAndSettle(); } +class _RouteVideoFactory { + final List<_RouteVideoPlayer> created = []; + + PlayerPort create(Episode episode) { + final player = _RouteVideoPlayer(episode); + created.add(player); + return player; + } +} + +class _RouteVideoPlayer extends VideoPlayerPort { + _RouteVideoPlayer(super.episode); + + @override + Future initialize() async { + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 5), + isInitialized: true, + isPlaying: true, + ); + } + + @override + Future play() async { + final value = controller.value; + controller.value = VideoPlayerValue( + duration: value.duration, + size: value.size, + position: value.position, + isInitialized: value.isInitialized, + isPlaying: true, + ); + } + + @override + Future pause() async { + final value = controller.value; + controller.value = VideoPlayerValue( + duration: value.duration, + size: value.size, + position: value.position, + isInitialized: value.isInitialized, + isPlaying: false, + ); + } +} + class _RecordingPlayer implements PlayerPort { bool playing = false; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 5868e65..f402be6 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -464,28 +464,28 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); }); - testWidgets('should_open_standalone_player_from_title_and_watch_all', ( + testWidgets('should_open_full_player_presentation_from_title_and_watch_all', ( tester, ) async { await _pumpRecommend(tester); await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.pumpAndSettle(); + expect(find.byType(PlayerPage), findsOneWidget); expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, + tester.widget(find.byType(PlayerPage)).homeMode, + isTrue, ); - await tester.binding.handlePopRoute(); + expect(find.byKey(const Key('player-back')), findsOneWidget); + tester.state(find.byType(Navigator).first).pop(); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, - ); + expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byKey(const Key('player-back')), findsOneWidget); }); testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', ( From 9d2d3b97a1bca9d59ca7b9f2d5a017b640d2f880 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 16:08:15 +0800 Subject: [PATCH 15/42] =?UTF-8?q?fix(player):=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=85=A8=E5=B1=8F=E8=B7=AF=E7=94=B1=E5=A4=8D=E7=94=A8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 241 ++++++++++++------- lib/pages/player/widgets/player_overlay.dart | 6 - test/player/player_fullscreen_test.dart | 63 ++++- test/recommend/recommend_sections_test.dart | 12 +- 4 files changed, 219 insertions(+), 103 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index d11f033..75627bc 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -55,6 +55,20 @@ const double _playerLoadingSize = 24; const double _playerLoadingStrokeWidth = 2; const double _feedRefreshTopGap = 56; +class PlayerPageHandoff { + PlayerPageHandoff({ + required this.logic, + required this.initialIndex, + required this.prepareSource, + }); + + final PlayerLogic logic; + final int initialIndex; + final Future Function() prepareSource; + PageController? destinationController; + VoidCallback? activateDestination; +} + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -82,6 +96,7 @@ class PlayerPage extends StatefulWidget { this.onChannelSelected, this.wallet, this.downloads, + this.handoff, }); final Drama drama; @@ -108,6 +123,7 @@ class PlayerPage extends StatefulWidget { final ValueChanged? onChannelSelected; final WalletController? wallet; final DownloadController? downloads; + final PlayerPageHandoff? handoff; @override State createState() => _PlayerPageState(); @@ -116,6 +132,8 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; + late final bool _ownsLogic; + late PageController _pageController; late final PlaybackThumbnailService _thumbnailService; late final SpriteThumbnailAxisService _spriteAxisService; PageRoute? _subscribedRoute; @@ -128,7 +146,11 @@ class _PlayerPageState extends State DateTime? _lastDoubleTapAt; DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; - bool _fullPlayerPresented = false; + bool _presentationActive = false; + bool _presentationSuspended = false; + bool _presentationReturning = false; + bool _leavingHandoff = false; + PageController? _returnPageController; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -138,13 +160,17 @@ class _PlayerPageState extends State @override void initState() { super.initState(); - logic = PlayerBinding().create( - history: context.read(), - playback: context.read(), - positionCache: context.read(), - wallet: widget.wallet, - downloads: widget.downloads, - ); + final handoff = widget.handoff; + _ownsLogic = handoff == null; + logic = + handoff?.logic ?? + PlayerBinding().create( + history: context.read(), + playback: context.read(), + positionCache: context.read(), + wallet: widget.wallet, + downloads: widget.downloads, + ); logic.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, @@ -152,10 +178,20 @@ class _PlayerPageState extends State _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); - logic.start( - factory: context.read(), - coordinator: context.read(), - ); + if (handoff == null) { + logic.start( + factory: context.read(), + coordinator: context.read(), + ); + _pageController = logic.pages; + } else { + final position = logic.visibleIndices.indexOf(handoff.initialIndex); + _pageController = PageController( + initialPage: position < 0 ? 0 : position, + ); + handoff.destinationController = _pageController; + logic.session.setRouteVisible(widget.active); + } } @override @@ -258,13 +294,13 @@ class _PlayerPageState extends State @override void didPushNext() { - if (_fullPlayerPresented) return; + if (_presentationActive) return; logic.onRouteCovered(); } @override void didPopNext() { - if (_fullPlayerPresented) return; + if (_presentationActive) return; logic.onRouteRevealed(); } @@ -276,7 +312,11 @@ class _PlayerPageState extends State ..removeListener(_handleSpriteFrameChanged) ..dispose(); _thumbnailService.dispose(); - logic.dispose(); + if (_ownsLogic) { + logic.dispose(); + } else { + _pageController.dispose(); + } super.dispose(); } @@ -513,6 +553,8 @@ class _PlayerPageState extends State } Widget _buildPlayer(BuildContext context) { + if (_presentationSuspended) return const SizedBox.expand(); + if (_presentationReturning) return _buildPresentationReturnLayer(); final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; @@ -523,7 +565,7 @@ class _PlayerPageState extends State final pages = NotificationListener( onNotification: _handlePageScroll, child: PageView.builder( - controller: logic.pages, + controller: _pageController, scrollDirection: Axis.vertical, allowImplicitScrolling: true, physics: widget.onRefreshFeed == null @@ -563,7 +605,7 @@ class _PlayerPageState extends State final pageLayer = widget.onRefreshFeed == null ? pages : FeedPullRefresh( - pageController: logic.pages, + pageController: _pageController, topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap, onRefresh: _refreshHomeFeed, @@ -573,7 +615,7 @@ class _PlayerPageState extends State fit: StackFit.expand, children: [ PlayerRenderStack( - pageController: logic.pages, + pageController: _pageController, slots: _renderSlots(indices), ), pageLayer, @@ -620,7 +662,7 @@ class _PlayerPageState extends State ), ], ); - return AnnotatedRegion( + final player = AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( statusBarColor: AppColors.transparent, @@ -641,6 +683,14 @@ class _PlayerPageState extends State ), ), ); + if (widget.handoff == null) return player; + return PopScope( + canPop: _leavingHandoff, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(_leaveHandoff(context)); + }, + child: player, + ); } void _openCurrentMore() { @@ -701,6 +751,18 @@ class _PlayerPageState extends State }; } + Widget _buildPresentationReturnLayer() { + final indices = logic.visibleIndices; + final controller = _returnPageController ?? _pageController; + return AnimatedBuilder( + animation: logic.session, + builder: (context, _) => PlayerRenderStack( + pageController: controller, + slots: _renderSlots(indices), + ), + ); + } + PlayerRenderSlotData _renderSlot( PlayerRenderSlotRole role, int? index, @@ -933,7 +995,7 @@ class _PlayerPageState extends State ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (widget.homeMode && !_fullPlayerPresented) + else if (widget.homeMode && !_presentationActive) HomeFeedOverlay( l10n: l10n, drama: drama, @@ -1024,9 +1086,8 @@ class _PlayerPageState extends State Widget _standardPlaybackOverlay( BuildContext context, int index, - InteractionController interactions, { - bool presentation = false, - }) { + InteractionController interactions, + ) { if (logic.pip || logic.pipPreparing) return const SizedBox.expand(); final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); @@ -1067,8 +1128,8 @@ class _PlayerPageState extends State ), scrubbing: _scrubbing, actions: PlayerOverlayActions( - onBack: presentation - ? () => Navigator.of(context).pop() + onBack: widget.handoff != null + ? () => unawaited(_leaveHandoff(context)) : widget.embedded ? null : () => unawaited(logic.leave(context)), @@ -1098,56 +1159,82 @@ class _PlayerPageState extends State onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, - onVerticalDragUpdate: presentation - ? _handleFullPlayerDragUpdate - : null, - onVerticalDragEnd: presentation ? _handleFullPlayerDragEnd : null, ); } Future _presentFullPlayer(BuildContext context, int index) async { - if (_fullPlayerPresented || !logic.pages.hasClients) return; - // The route owns presentation only; PlaybackSession keeps the controller. - final interactions = context.read(); + if (_presentationActive || !_pageController.hasClients) return; + final sourcePageController = _pageController; + final handoff = PlayerPageHandoff( + logic: logic, + initialIndex: index, + prepareSource: _prepareSourceAfterFullPlayer, + ); + handoff.activateDestination = () { + final destination = handoff.destinationController; + if (destination != null) logic.pages = destination; + }; _logFullPlayerRoute('present'); - setState(() => _fullPlayerPresented = true); - await Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - barrierColor: null, - barrierDismissible: false, - transitionDuration: const Duration(milliseconds: 180), - reverseTransitionDuration: const Duration(milliseconds: 160), - pageBuilder: (routeContext, _, _) => Material( - color: AppColors.transparent, - child: Scaffold( - backgroundColor: AppColors.transparent, - body: ListenableBuilder( - listenable: logic, - builder: (context, _) => AnimatedBuilder( - animation: logic.session, - builder: (context, _) { - final current = logic.session.currentIndex; - return _standardPlaybackOverlay( - routeContext, - current, - interactions, - presentation: true, - ); - }, - ), - ), - ), + setState(() => _presentationActive = true); + final route = Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: logic.queue.entryAt(index).drama, + initialIndex: index, + handoff: handoff, + wallet: logic.wallet, + downloads: logic.downloads, ), - transitionsBuilder: (context, animation, _, child) => - FadeTransition(opacity: animation, child: child), ), ); + await WidgetsBinding.instance.endOfFrame; + if (mounted) { + setState(() => _presentationSuspended = true); + await WidgetsBinding.instance.endOfFrame; + } + sourcePageController.dispose(); + handoff.activateDestination?.call(); + await route; if (!mounted) return; - setState(() => _fullPlayerPresented = false); + final currentPage = logic.visibleIndices.indexOf( + logic.session.currentIndex, + ); + final restoredPage = + _returnPageController ?? + PageController(initialPage: currentPage < 0 ? 0 : currentPage); + logic.pages = restoredPage; + _pageController = restoredPage; + logic.attach(widget, isMounted: () => mounted); + setState(() { + _presentationActive = false; + _presentationSuspended = false; + _presentationReturning = false; + }); + logic.session.setRouteVisible(widget.active); _logFullPlayerRoute('restored'); } + Future _prepareSourceAfterFullPlayer() async { + if (!mounted) return; + final page = logic.visibleIndices.indexOf(logic.session.currentIndex); + _returnPageController = PageController( + initialPage: page < 0 ? 0 : page, + ); + logic.attach(widget, isMounted: () => mounted); + setState(() { + _presentationSuspended = false; + _presentationReturning = true; + }); + await WidgetsBinding.instance.endOfFrame; + } + + Future _leaveHandoff(BuildContext context) async { + if (_leavingHandoff) return; + setState(() => _leavingHandoff = true); + await widget.handoff?.prepareSource(); + if (context.mounted) Navigator.of(context).pop(); + } + void _logFullPlayerRoute(String phase) { if (!kDebugMode) return; final active = logic.session.active; @@ -1161,32 +1248,4 @@ class _PlayerPageState extends State ); } - void _handleFullPlayerDragUpdate(DragUpdateDetails details) { - if (!logic.pages.hasClients) return; - final position = logic.pages.position; - final next = (position.pixels - details.delta.dy).clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if ((next - position.pixels).abs() < 0.5) return; - position.jumpTo(next); - } - - void _handleFullPlayerDragEnd(DragEndDetails details) { - if (!logic.pages.hasClients || logic.visibleIndices.isEmpty) return; - final position = logic.pages.position; - final page = position.pixels / position.viewportDimension; - final velocity = details.primaryVelocity ?? 0; - var target = page.round(); - if (velocity < -260) target = page.floor() + 1; - if (velocity > 260) target = page.ceil() - 1; - target = target.clamp(0, logic.visibleIndices.length - 1); - unawaited( - logic.pages.animateToPage( - target, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOut, - ), - ); - } } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index a2ce47b..0a89365 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -36,8 +36,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { required this.actions, required this.controlsVisible, required this.scrubbing, - this.onVerticalDragUpdate, - this.onVerticalDragEnd, }); /// 展示数据。 @@ -51,8 +49,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { /// 正在拖动进度条。拖动期间清屏,只留进度条。 final bool scrubbing; - final GestureDragUpdateCallback? onVerticalDragUpdate; - final GestureDragEndCallback? onVerticalDragEnd; @override Widget build(BuildContext context) { @@ -69,8 +65,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => actions.onBoostEnd(), onLongPressCancel: actions.onBoostEnd, - onVerticalDragUpdate: onVerticalDragUpdate, - onVerticalDragEnd: onVerticalDragEnd, ), ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 0d79293..b7ee656 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -23,6 +23,7 @@ import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart'; +import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -104,13 +105,15 @@ void main() { expect(recommendPlayer, findsOneWidget); expect(renderStack, findsOneWidget); final playerState = tester.state(recommendPlayer); - final renderState = tester.state(renderStack); final currentSurface = find.descendant( of: recommendPlayer, matching: find.byKey( const ValueKey('player-render-slot-current'), ), ); + final sourceRender = tester.widget(renderStack); + final sourceIndex = + sourceRender.slots[PlayerRenderSlotRole.current]?.index; final controller = factory.created.first.controller; final createdBefore = factory.created.length; final requestsBefore = playback.requests.length; @@ -126,19 +129,37 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); expect(find.byType(PlayerPage), findsOneWidget); + final fullPlayer = tester.widget(find.byType(PlayerPage)); + expect(fullPlayer.homeMode, isFalse); + expect(fullPlayer.handoff, isNotNull); + expect(fullPlayer.handoff!.initialIndex, sourceIndex); + expect(fullPlayer.handoff!.logic.session.currentIndex, sourceIndex); + expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + expect(find.byKey(const Key('recommend-top-nav')), findsNothing); + expect(find.byKey(const Key('home-feed-gesture')), findsNothing); expect(find.byKey(const Key('player-back')), findsOneWidget); expect(factory.created.length, createdBefore); expect(playback.requests.length, requestsBefore); expect(controller.value.position, const Duration(seconds: 5)); + final fullCurrentSurface = find.descendant( + of: find.byType(PlayerPage), + matching: find.byKey( + const ValueKey('player-render-slot-current'), + ), + ); expect( find.descendant( - of: currentSurface, + of: fullCurrentSurface, matching: find.byKey(const Key('player-single-placeholder')), ), findsNothing, ); + expect( + tester.widget(fullCurrentSurface).controller, + same(controller), + ); - tester.state(find.byType(Navigator).first).pop(); + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); @@ -147,8 +168,9 @@ void main() { expect(factory.created.length, createdBefore); expect(playback.requests.length, requestsBefore); expect(controller.value.position, const Duration(seconds: 5)); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); expect(tester.state(recommendPlayer), same(playerState)); - expect(tester.state(renderStack), same(renderState)); expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); expect( find.descendant( @@ -157,6 +179,10 @@ void main() { ), findsNothing, ); + expect( + tester.widget(currentSurface).controller, + same(controller), + ); }); testWidgets('should_swipe_full_player_without_creating_another_session', ( @@ -179,6 +205,35 @@ void main() { expect(find.byType(PlayerPage), findsOneWidget); expect(factory.created.length, lessThanOrEqualTo(createdBefore + 1)); expect(find.byKey(const Key('player-render-stack')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + }); + + testWidgets('should_show_one_paused_overlay_when_entering_full_player', ( + tester, + ) async { + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-feed-gesture'))); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + expect(find.byKey(const Key('recommend-top-nav')), findsNothing); + + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); testWidgets('should_render_player_loading_indicators_at_compact_size', ( diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index f402be6..a85104d 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -474,10 +474,11 @@ void main() { expect(find.byType(PlayerPage), findsOneWidget); expect( tester.widget(find.byType(PlayerPage)).homeMode, - isTrue, + isFalse, ); expect(find.byKey(const Key('player-back')), findsOneWidget); - tester.state(find.byType(Navigator).first).pop(); + expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); @@ -485,7 +486,14 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); expect(find.byType(PlayerPage), findsOneWidget); + expect( + tester.widget(find.byType(PlayerPage)).homeMode, + isFalse, + ); expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); }); testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', ( From 440188f2617236880e88b3b0e3350eddda256663 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 16:20:31 +0800 Subject: [PATCH 16/42] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=87=E9=9B=86=E8=BF=94=E5=9B=9E=E6=8E=A8=E8=8D=90=E9=A1=B5?= =?UTF-8?q?=E9=BB=91=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/playback_queue.dart | 3 + lib/core/data/services/playback_session.dart | 41 ++++++ lib/pages/player/player_logic.dart | 62 +++++++++ lib/pages/player/player_view.dart | 92 +++++++++++-- test/player/player_fullscreen_test.dart | 136 +++++++++++++++---- 5 files changed, 297 insertions(+), 37 deletions(-) diff --git a/lib/core/data/services/playback_queue.dart b/lib/core/data/services/playback_queue.dart index bf14670..4f1a04f 100644 --- a/lib/core/data/services/playback_queue.dart +++ b/lib/core/data/services/playback_queue.dart @@ -4,6 +4,9 @@ import '../models/drama.dart'; /// A player session has one navigation meaning: browse dramas or browse episodes. class PlaybackQueue { + PlaybackQueue.fromEntries(List entries) + : entries = List.unmodifiable(entries); + PlaybackQueue.home(List dramas) : entries = List.unmodifiable( dramas diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 43727cb..cd3a82d 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -137,6 +137,47 @@ class PlaybackSession extends ChangeNotifier { if (_visible && !_overlayOpen) _warmNeighbors(this); } + /// 在剧维度和集维度之间切换队列,但保留当前 active controller 与播放状态。 + void remapQueuePreservingActive( + List nextEpisodes, { + required int currentIndex, + int? preloadIndex, + int? previousIndex, + bool notify = true, + }) { + if (_closed || + currentIndex < 0 || + currentIndex >= nextEpisodes.length || + _active == null) { + return; + } + _generation++; + episodes + ..clear() + ..addAll(nextEpisodes); + _active!.index = currentIndex; + _currentIndex = currentIndex; + _selected = true; + _loading = false; + _error = null; + _rememberNeighbors(preloadIndex, previousIndex); + final staleNext = _next; + final stalePrevious = _previous; + final staleHandoff = _handoff; + _next = null; + _previous = null; + _handoff = null; + if (notify) _notify(); + unawaited( + _enqueue(() async { + await _retire(staleNext); + await _retire(stalePrevious); + await _retire(staleHandoff); + }), + ); + if (_visible && !_overlayOpen) _warmNeighbors(this); + } + void _rememberNeighbors(int? preloadIndex, int? previousIndex) { _nextIndex = preloadIndex; _previousIndex = previousIndex; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index b474a6b..dece37b 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -212,6 +212,68 @@ class PlayerLogic extends ChangeNotifier { unawaited(selectEpisode(initialIndex)); } + /// 独立播放页把 feed 的“剧索引”切换成 series 的“集索引”,保留 active player。 + void adoptSeriesHandoff({ + required Drama drama, + required List seriesEpisodes, + required int episodeIndex, + }) { + if (episodeIndex < 0 || episodeIndex >= seriesEpisodes.length) return; + homeMode = false; + embedded = false; + feedDramas = null; + pageDrama = drama; + initialIndex = episodeIndex; + queue = PlaybackQueue.series(drama); + visibleIndices = List.generate(queue.length, (i) => i); + sessionEpisodes + ..clear() + ..addAll(seriesEpisodes); + session.remapQueuePreservingActive( + List.of(seriesEpisodes), + currentIndex: episodeIndex, + notify: false, + preloadIndex: episodeIndex + 1 < seriesEpisodes.length + ? episodeIndex + 1 + : null, + previousIndex: episodeIndex > 0 ? episodeIndex - 1 : null, + ); + } + + /// 返回推荐页时恢复剧维度 queue,并把当前集写回该剧的 feed 条目。 + void restoreFeedHandoff({ + required PlayerPage page, + required PlaybackQueue sourceQueue, + required List sourceEpisodes, + required int sourceIndex, + }) { + if (sourceIndex < 0 || sourceIndex >= sourceQueue.length) return; + final currentEpisode = session.currentEpisode; + final feedEpisodes = List.of(sourceEpisodes); + feedEpisodes[sourceIndex] = currentEpisode; + final entries = List.of(sourceQueue.entries); + entries[sourceIndex] = PlaybackEntry( + entries[sourceIndex].drama, + currentEpisode, + ); + feedDramas = page.feedDramas; + pageDrama = page.drama; + homeMode = page.homeMode; + embedded = page.embedded; + initialIndex = sourceIndex; + queue = PlaybackQueue.fromEntries(entries); + visibleIndices = List.generate(queue.length, (i) => i); + sessionEpisodes + ..clear() + ..addAll(feedEpisodes); + session.remapQueuePreservingActive( + List.of(feedEpisodes), + currentIndex: sourceIndex, + preloadIndex: nextVisibleIndex(sourceIndex), + previousIndex: previousVisibleIndex(sourceIndex), + ); + } + /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 void updateFeed( List dramas, { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 75627bc..87f9feb 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -10,10 +10,12 @@ 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/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; 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_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/playback_thumbnail_service.dart'; @@ -58,12 +60,22 @@ const double _feedRefreshTopGap = 56; class PlayerPageHandoff { PlayerPageHandoff({ required this.logic, - required this.initialIndex, + required this.sourceQueue, + required this.sourceEpisodes, + required this.sourceIndex, + required this.seriesDrama, + required this.seriesEpisodes, + required this.episodeIndex, required this.prepareSource, }); final PlayerLogic logic; - final int initialIndex; + final PlaybackQueue sourceQueue; + final List sourceEpisodes; + final int sourceIndex; + final Drama seriesDrama; + final List seriesEpisodes; + final int episodeIndex; final Future Function() prepareSource; PageController? destinationController; VoidCallback? activateDestination; @@ -150,6 +162,8 @@ class _PlayerPageState extends State bool _presentationSuspended = false; bool _presentationReturning = false; bool _leavingHandoff = false; + bool _preparingFullPlayer = false; + PlayerPageHandoff? _activeHandoff; PageController? _returnPageController; final Set _precachedPosterUrls = {}; @@ -185,10 +199,12 @@ class _PlayerPageState extends State ); _pageController = logic.pages; } else { - final position = logic.visibleIndices.indexOf(handoff.initialIndex); - _pageController = PageController( - initialPage: position < 0 ? 0 : position, + logic.adoptSeriesHandoff( + drama: handoff.seriesDrama, + seriesEpisodes: handoff.seriesEpisodes, + episodeIndex: handoff.episodeIndex, ); + _pageController = PageController(initialPage: handoff.episodeIndex); handoff.destinationController = _pageController; logic.session.setRouteVisible(widget.active); } @@ -1163,13 +1179,56 @@ class _PlayerPageState extends State } Future _presentFullPlayer(BuildContext context, int index) async { - if (_presentationActive || !_pageController.hasClients) return; + if (_presentationActive || + _preparingFullPlayer || + !_pageController.hasClients) { + return; + } + _preparingFullPlayer = true; + final sourceIndex = logic.session.currentIndex; + final sourceEpisode = logic.session.currentEpisode; + final sourceQueue = logic.queue; + final sourceEpisodes = List.of(logic.sessionEpisodes); + var seriesDrama = sourceQueue.entryAt(sourceIndex).drama; + try { + seriesDrama = await context.read().requireDetail( + seriesDrama, + ); + } catch (_) { + // 详情不可用时仍使用当前剧已知的集列表。 + } + _preparingFullPlayer = false; + if (!mounted || + !context.mounted || + logic.session.currentIndex != sourceIndex || + logic.session.currentEpisode.id != sourceEpisode.id) { + return; + } + final seriesEpisodes = List.of(seriesDrama.episodes); + var episodeIndex = seriesEpisodes.indexWhere( + (episode) => episode.id == sourceEpisode.id, + ); + if (episodeIndex < 0) { + episodeIndex = seriesEpisodes.indexWhere( + (episode) => episode.number == sourceEpisode.number, + ); + } + if (episodeIndex < 0) { + seriesEpisodes.insert(0, sourceEpisode); + episodeIndex = 0; + } final sourcePageController = _pageController; final handoff = PlayerPageHandoff( logic: logic, - initialIndex: index, + sourceQueue: sourceQueue, + sourceEpisodes: sourceEpisodes, + sourceIndex: sourceIndex, + seriesDrama: seriesDrama, + seriesEpisodes: seriesEpisodes, + episodeIndex: episodeIndex, prepareSource: _prepareSourceAfterFullPlayer, ); + _activeHandoff = handoff; handoff.activateDestination = () { final destination = handoff.destinationController; if (destination != null) logic.pages = destination; @@ -1179,8 +1238,8 @@ class _PlayerPageState extends State final route = Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlayerPage( - drama: logic.queue.entryAt(index).drama, - initialIndex: index, + drama: seriesDrama, + initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, @@ -1210,17 +1269,23 @@ class _PlayerPageState extends State _presentationSuspended = false; _presentationReturning = false; }); + _activeHandoff = null; logic.session.setRouteVisible(widget.active); _logFullPlayerRoute('restored'); } Future _prepareSourceAfterFullPlayer() async { if (!mounted) return; - final page = logic.visibleIndices.indexOf(logic.session.currentIndex); - _returnPageController = PageController( - initialPage: page < 0 ? 0 : page, - ); + final handoff = _activeHandoff; + if (handoff == null) return; logic.attach(widget, isMounted: () => mounted); + logic.restoreFeedHandoff( + page: widget, + sourceQueue: handoff.sourceQueue, + sourceEpisodes: handoff.sourceEpisodes, + sourceIndex: handoff.sourceIndex, + ); + _returnPageController = PageController(initialPage: handoff.sourceIndex); setState(() { _presentationSuspended = false; _presentationReturning = true; @@ -1247,5 +1312,4 @@ class _PlayerPageState extends State 'position=${video?.controller.value.position.inMilliseconds ?? '-'}', ); } - } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b7ee656..92e4388 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -95,9 +95,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - final recommendPlayer = find.byKey( - const Key('recommend-feed-player'), - ); + final recommendPlayer = find.byKey(const Key('recommend-feed-player')); final renderStack = find.descendant( of: recommendPlayer, matching: find.byType(PlayerRenderStack), @@ -107,15 +105,12 @@ void main() { final playerState = tester.state(recommendPlayer); final currentSurface = find.descendant( of: recommendPlayer, - matching: find.byKey( - const ValueKey('player-render-slot-current'), - ), + matching: find.byKey(const ValueKey('player-render-slot-current')), ); final sourceRender = tester.widget(renderStack); - final sourceIndex = - sourceRender.slots[PlayerRenderSlotRole.current]?.index; + final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index; + final currentEpisodeId = factory.episodes.first.id; final controller = factory.created.first.controller; - final createdBefore = factory.created.length; final requestsBefore = playback.requests.length; expect(controller.value.position, const Duration(seconds: 5)); expect( @@ -132,20 +127,24 @@ void main() { final fullPlayer = tester.widget(find.byType(PlayerPage)); expect(fullPlayer.homeMode, isFalse); expect(fullPlayer.handoff, isNotNull); - expect(fullPlayer.handoff!.initialIndex, sourceIndex); - expect(fullPlayer.handoff!.logic.session.currentIndex, sourceIndex); + expect(fullPlayer.handoff!.sourceIndex, sourceIndex); + expect( + fullPlayer.handoff!.logic.session.currentIndex, + fullPlayer.handoff!.episodeIndex, + ); expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byKey(const Key('recommend-top-nav')), findsNothing); expect(find.byKey(const Key('home-feed-gesture')), findsNothing); expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(factory.created.length, createdBefore); + expect( + factory.episodes.where((episode) => episode.id == currentEpisodeId), + hasLength(1), + ); expect(playback.requests.length, requestsBefore); expect(controller.value.position, const Duration(seconds: 5)); final fullCurrentSurface = find.descendant( of: find.byType(PlayerPage), - matching: find.byKey( - const ValueKey('player-render-slot-current'), - ), + matching: find.byKey(const ValueKey('player-render-slot-current')), ); expect( find.descendant( @@ -165,7 +164,10 @@ void main() { expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(recommendPlayer, findsOneWidget); expect(renderStack, findsOneWidget); - expect(factory.created.length, createdBefore); + expect( + factory.episodes.where((episode) => episode.id == currentEpisodeId), + hasLength(1), + ); expect(playback.requests.length, requestsBefore); expect(controller.value.position, const Duration(seconds: 5)); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); @@ -195,6 +197,10 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); + final fullPlayer = tester.widget(find.byType(PlayerPage)); + final handoff = fullPlayer.handoff!; + final dramaId = handoff.seriesDrama.id; + final firstEpisodeId = handoff.logic.session.currentEpisode.id; final createdBefore = factory.created.length; await tester.drag( find.byKey(const Key('player-video-tap')), @@ -205,8 +211,44 @@ void main() { expect(find.byType(PlayerPage), findsOneWidget); expect(factory.created.length, lessThanOrEqualTo(createdBefore + 1)); expect(find.byKey(const Key('player-render-stack')), findsOneWidget); + final switchedEpisode = handoff.logic.session.currentEpisode; + expect(switchedEpisode.id, isNot(firstEpisodeId)); + expect(switchedEpisode.number, 2); + expect( + handoff.logic.queue.entryAt(handoff.logic.session.currentIndex).drama.id, + dramaId, + ); + expect(handoff.logic.session.userWantsPlay, isTrue); + expect( + factory.episodes.where((episode) => episode.id == switchedEpisode.id), + hasLength(1), + ); + final switchedVideo = handoff.logic.session.active! as VideoPlayerPort; + final switchedController = switchedVideo.controller; + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); + expect(handoff.logic.session.currentIndex, handoff.sourceIndex); + expect( + handoff.logic.queue.entryAt(handoff.sourceIndex).episode.id, + switchedEpisode.id, + ); + final sourceCurrentSurface = find.descendant( + of: find.byType(PlayerPage), + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + expect( + tester.widget(sourceCurrentSurface).controller, + same(switchedController), + ); + expect(switchedController.value.position, const Duration(seconds: 5)); + expect( + find.descendant( + of: sourceCurrentSurface, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, + ); }); testWidgets('should_show_one_paused_overlay_when_entering_full_player', ( @@ -236,6 +278,48 @@ void main() { expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); + testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + final feedPage = find + .descendant( + of: find.byKey(const Key('recommend-feed-player')), + matching: find.byType(PageView), + ) + .first; + await tester.drag(feedPage, const Offset(0, -620)); + await tester.pumpAndSettle(); + final sourceRender = tester.widget( + find.byType(PlayerRenderStack), + ); + final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index; + expect(sourceIndex, isNotNull); + expect(sourceIndex, greaterThan(0)); + final feedIndex = sourceIndex!; + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + final fullPlayer = tester.widget(find.byType(PlayerPage)); + final handoff = fullPlayer.handoff!; + expect(handoff.sourceIndex, feedIndex); + expect(handoff.episodeIndex, 0); + expect(handoff.logic.session.currentIndex, handoff.episodeIndex); + expect( + handoff.seriesDrama.id, + handoff.sourceQueue.entryAt(feedIndex).drama.id, + ); + expect( + handoff.logic.session.currentEpisode.id, + handoff.sourceEpisodes[feedIndex].id, + ); + + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + }); + testWidgets('should_render_player_loading_indicators_at_compact_size', ( tester, ) async { @@ -940,10 +1024,7 @@ void main() { ), ], ); - await _pumpApp( - tester, - catalog: FakeCatalogRepository(dramas: [longCopy]), - ); + await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy])); await _openSeriesPlayer( tester, cardKey: const Key('home-feed-card-long-copy-demo'), @@ -966,7 +1047,9 @@ void main() { ); expect(tester.widget(synopsis).maxLines, 2); expect( - tester.widget(find.byKey(const Key('player-synopsis-gap'))).padding, + tester + .widget(find.byKey(const Key('player-synopsis-gap'))) + .padding, const EdgeInsets.only(left: 6), ); final synopsisRect = tester.getRect(synopsis); @@ -983,10 +1066,15 @@ void main() { find.descendant(of: toggle, matching: find.text('Less')), findsOneWidget, ); - expect(tester.widget(find.byKey(const Key('player-synopsis'))).maxLines, isNull); + expect( + tester.widget(find.byKey(const Key('player-synopsis'))).maxLines, + isNull, + ); final lessRect = tester.getRect(toggle); final textRect = tester.getRect(find.byKey(const Key('player-synopsis'))); - final block = tester.getRect(find.byKey(const Key('player-episode-synopsis'))); + final block = tester.getRect( + find.byKey(const Key('player-episode-synopsis')), + ); expect(lessRect.top, greaterThanOrEqualTo(textRect.bottom - 1)); expect(block.right - lessRect.right, lessThanOrEqualTo(2)); @@ -1596,10 +1684,12 @@ Future _pumpApp( class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; + final List episodes = []; PlayerPort create(Episode episode) { final player = _RouteVideoPlayer(episode); created.add(player); + episodes.add(episode); return player; } } From 6667330ce0119d6913527085c64428f82d358925 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 16:37:37 +0800 Subject: [PATCH 17/42] =?UTF-8?q?fix(player):=20=E5=88=86=E7=A6=BB?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E4=B8=8E=E6=92=AD=E6=94=BE=E9=A1=B5=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 1 + lib/core/data/services/playback_queue.dart | 3 - lib/core/data/services/playback_session.dart | 54 +--- .../services/playback_session_controls.dart | 63 +++- lib/pages/player/player_logic.dart | 114 +++---- lib/pages/player/player_view.dart | 122 ++------ test/playback/playback_session_test.dart | 27 ++ test/player/player_fullscreen_test.dart | 290 ++++++++---------- 8 files changed, 312 insertions(+), 362 deletions(-) diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index 17d67d1..7c86289 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -69,6 +69,7 @@ Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { /// 当前集已经出画后,按手势方向静默准备上一集和下一集。 void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) { + if (!session.warmNeighborsEnabled) return; final request = session._generation; final nextTask = _warmOne(session, request, session._nextIndex, next: true); final previousTask = _warmOne( diff --git a/lib/core/data/services/playback_queue.dart b/lib/core/data/services/playback_queue.dart index 4f1a04f..bf14670 100644 --- a/lib/core/data/services/playback_queue.dart +++ b/lib/core/data/services/playback_queue.dart @@ -4,9 +4,6 @@ import '../models/drama.dart'; /// A player session has one navigation meaning: browse dramas or browse episodes. class PlaybackQueue { - PlaybackQueue.fromEntries(List entries) - : entries = List.unmodifiable(entries); - PlaybackQueue.home(List dramas) : entries = List.unmodifiable( dramas diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index cd3a82d..700648f 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -27,6 +27,7 @@ class PlaybackSession extends ChangeNotifier { this.activate, PlaybackCoordinator? coordinator, this.waitForFrame, + this.warmNeighborsEnabled = true, }) : _coordinator = coordinator ?? PlaybackCoordinator(); final List episodes; @@ -39,6 +40,7 @@ class PlaybackSession extends ChangeNotifier { /// Runs only when a slot becomes current (e.g. progress listeners). final PlayerPreparation? activate; final Future Function()? waitForFrame; + final bool warmNeighborsEnabled; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; @@ -80,6 +82,8 @@ class PlaybackSession extends ChangeNotifier { String? get error => _error; // Includes initialization and asynchronous disposal, until native release ends. int get livePlayerCount => _live.length; + bool get activeSlotActivated => _active?.activated ?? false; + bool get activeSlotPlaying => _active?.playing ?? false; int? get preloadedIndex => _next?.prepared == true ? _next?.index : null; int? get previousPreloadedIndex => _previous?.prepared == true ? _previous?.index : null; @@ -137,50 +141,9 @@ class PlaybackSession extends ChangeNotifier { if (_visible && !_overlayOpen) _warmNeighbors(this); } - /// 在剧维度和集维度之间切换队列,但保留当前 active controller 与播放状态。 - void remapQueuePreservingActive( - List nextEpisodes, { - required int currentIndex, - int? preloadIndex, - int? previousIndex, - bool notify = true, - }) { - if (_closed || - currentIndex < 0 || - currentIndex >= nextEpisodes.length || - _active == null) { - return; - } - _generation++; - episodes - ..clear() - ..addAll(nextEpisodes); - _active!.index = currentIndex; - _currentIndex = currentIndex; - _selected = true; - _loading = false; - _error = null; - _rememberNeighbors(preloadIndex, previousIndex); - final staleNext = _next; - final stalePrevious = _previous; - final staleHandoff = _handoff; - _next = null; - _previous = null; - _handoff = null; - if (notify) _notify(); - unawaited( - _enqueue(() async { - await _retire(staleNext); - await _retire(stalePrevious); - await _retire(staleHandoff); - }), - ); - if (_visible && !_overlayOpen) _warmNeighbors(this); - } - void _rememberNeighbors(int? preloadIndex, int? previousIndex) { - _nextIndex = preloadIndex; - _previousIndex = previousIndex; + _nextIndex = warmNeighborsEnabled ? preloadIndex : null; + _previousIndex = warmNeighborsEnabled ? previousIndex : null; } Future _enqueue(Future Function() operation) { @@ -247,10 +210,13 @@ class PlaybackSession extends ChangeNotifier { int index, { int? preloadIndex, int? previousIndex, + bool? userWantsPlay, }) async { if (_closed || index < 0 || index >= episodes.length) return; // 切换到不同剧集时恢复播放;仍停留在同一集时保留用户暂停。 - if (!_selected || index != _currentIndex) { + if (userWantsPlay != null) { + _userWantsPlay = userWantsPlay; + } else if (!_selected || index != _currentIndex) { _userWantsPlay = true; } final request = ++_generation; diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index 293f070..cf7c6e1 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -6,10 +6,71 @@ part of 'playback_session.dart'; extension PlaybackSessionControls on PlaybackSession { /// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。 void prewarmNeighbors({required bool forward}) { - if (!_visible || _overlayOpen || _active?.prepared != true) return; + if (!warmNeighborsEnabled || + !_visible || + _overlayOpen || + _active?.prepared != true) { + return; + } _warmNeighbors(this, preferredNext: forward); } + /// 路由交接时同步暂停当前原生播放器,controller 仍由 session 持有。 + Future pauseForRouteHandoff() { + if (_closed) return Future.value(); + _routeVisible = false; + return _enqueue(() async { + final slot = _active; + if (slot == null) return; + await slot.player.pause(); + slot.playing = false; + }); + } + + /// 路由恢复可见后立即同步当前 controller,并等待原生播放状态落地。 + Future resumeForRouteHandoff() { + if (_closed) return Future.value(); + _routeVisible = true; + return _enqueue(() async { + final slot = _active; + if (kDebugMode) { + debugPrint( + '[feed-handoff] phase=resume slot=${slot?.index} ' + 'activated=${slot?.activated} playing=${slot?.playing} ' + 'retiring=${slot?.retiring} current=$_currentIndex ' + 'visible=$_visible user=$_userWantsPlay loading=$_loading', + ); + } + if (slot == null || slot.retiring) return; + if (!slot.activated) { + try { + await activate?.call(episodes[slot.index], slot.player); + } catch (error) { + if (kDebugMode) { + debugPrint('[feed-handoff] phase=activation-failed error=$error'); + } + } + if (_closed || _active != slot || slot.retiring) { + if (kDebugMode) { + debugPrint( + '[feed-handoff] phase=activation-aborted closed=$_closed ' + 'activeSame=${identical(_active, slot)} retiring=${slot.retiring}', + ); + } + return; + } + slot.activated = true; + } + await _applyPlayback(slot); + if (kDebugMode) { + debugPrint( + '[feed-handoff] phase=resumed activated=${slot.activated} ' + 'playing=${slot.playing}', + ); + } + }); + } + /// 选中锁定集时立即停播并释放所有原生播放器,不创建新片源。 Future selectLocked( int index, { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index dece37b..cb181a8 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -192,6 +192,7 @@ class PlayerLogic extends ChangeNotifier { void start({ required PlayerFactory factory, required PlaybackCoordinator coordinator, + bool initialUserWantsPlay = true, }) { _started = true; _sessionFactory = factory; @@ -209,69 +210,41 @@ class PlayerLogic extends ChangeNotifier { session.setRouteVisible(false); } unawaited(loadResumePoint()); - unawaited(selectEpisode(initialIndex)); - } - - /// 独立播放页把 feed 的“剧索引”切换成 series 的“集索引”,保留 active player。 - void adoptSeriesHandoff({ - required Drama drama, - required List seriesEpisodes, - required int episodeIndex, - }) { - if (episodeIndex < 0 || episodeIndex >= seriesEpisodes.length) return; - homeMode = false; - embedded = false; - feedDramas = null; - pageDrama = drama; - initialIndex = episodeIndex; - queue = PlaybackQueue.series(drama); - visibleIndices = List.generate(queue.length, (i) => i); - sessionEpisodes - ..clear() - ..addAll(seriesEpisodes); - session.remapQueuePreservingActive( - List.of(seriesEpisodes), - currentIndex: episodeIndex, - notify: false, - preloadIndex: episodeIndex + 1 < seriesEpisodes.length - ? episodeIndex + 1 - : null, - previousIndex: episodeIndex > 0 ? episodeIndex - 1 : null, + unawaited( + selectEpisode( + initialIndex, + userWantsPlay: initialUserWantsPlay ? null : false, + ), ); } - /// 返回推荐页时恢复剧维度 queue,并把当前集写回该剧的 feed 条目。 - void restoreFeedHandoff({ - required PlayerPage page, - required PlaybackQueue sourceQueue, - required List sourceEpisodes, - required int sourceIndex, - }) { - if (sourceIndex < 0 || sourceIndex >= sourceQueue.length) return; - final currentEpisode = session.currentEpisode; - final feedEpisodes = List.of(sourceEpisodes); - feedEpisodes[sourceIndex] = currentEpisode; - final entries = List.of(sourceQueue.entries); - entries[sourceIndex] = PlaybackEntry( - entries[sourceIndex].drama, - currentEpisode, - ); - feedDramas = page.feedDramas; - pageDrama = page.drama; - homeMode = page.homeMode; - embedded = page.embedded; - initialIndex = sourceIndex; - queue = PlaybackQueue.fromEntries(entries); - visibleIndices = List.generate(queue.length, (i) => i); - sessionEpisodes - ..clear() - ..addAll(feedEpisodes); - session.remapQueuePreservingActive( - List.of(feedEpisodes), - currentIndex: sourceIndex, - preloadIndex: nextVisibleIndex(sourceIndex), - previousIndex: previousVisibleIndex(sourceIndex), - ); + /// 独立播放页覆盖首页时只暂停并保存进度,首页 session 与 controller 保持存活。 + Future pauseForFullPlayer() async { + if (!_started) return; + cancelScrub(); + await session.pauseForRouteHandoff(); + await saveCurrentProgress(); + } + + /// 返回首页前把仍存活的 feed controller 定位到第 1 集起点。 + Future prepareFeedResume() async { + if (!_started) return; + await session.playbackSettled; + final player = session.active; + if (player is VideoPlayerPort && player.controller.value.isInitialized) { + await player.seekTo(Duration.zero); + } + } + + /// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。 + Future resumeAfterFullPlayer() async { + if (!_started) return; + if (visibleIndices.isNotEmpty) { + await session.resumeForRouteHandoff(); + } else { + session.setRouteVisible(false); + await session.playbackSettled; + } } /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 @@ -310,6 +283,7 @@ class PlayerLogic extends ChangeNotifier { activate: activatePlayer, coordinator: coordinator, waitForFrame: _waitForFrame, + warmNeighborsEnabled: !homeMode, ); } @@ -1053,7 +1027,11 @@ class PlayerLogic extends ChangeNotifier { } } - Future selectEpisode(int index, {int? revision}) async { + Future selectEpisode( + int index, { + int? revision, + bool? userWantsPlay, + }) async { final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; cancelScrub(); @@ -1088,7 +1066,7 @@ class PlayerLogic extends ChangeNotifier { return; } if (!ready) return; - await _selectVisible(index); + await _selectVisible(index, userWantsPlay: userWantsPlay); } /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 @@ -1098,11 +1076,12 @@ class PlayerLogic extends ChangeNotifier { } /// 按可见队列选择一集,并预热上一集与下一集。 - Future _selectVisible(int index) { + Future _selectVisible(int index, {bool? userWantsPlay}) { return session.select( index, preloadIndex: nextVisibleIndex(index), previousIndex: previousVisibleIndex(index), + userWantsPlay: userWantsPlay, ); } @@ -1237,9 +1216,7 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { - session.setRouteVisible(false); - await session.playbackSettled; - await saveCurrentProgress(); + await pauseForFullPlayer(); if (!context.mounted) return; await Navigator.of(context).push( FadePageRoute( @@ -1248,10 +1225,13 @@ class PlayerLogic extends ChangeNotifier { initialIndex: index, wallet: wallet, downloads: downloads, + isolatePlaybackSession: true, ), ), ); - if (isMounted()) session.setRouteVisible(active); + if (!isMounted()) return; + await prepareFeedResume(); + await resumeAfterFullPlayer(); } Future showEpisodes(BuildContext context) async { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 87f9feb..92a60f0 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -15,7 +15,6 @@ import '../../core/data/services/favorite_controller.dart'; 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_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/playback_thumbnail_service.dart'; @@ -59,26 +58,12 @@ const double _feedRefreshTopGap = 56; class PlayerPageHandoff { PlayerPageHandoff({ - required this.logic, - required this.sourceQueue, - required this.sourceEpisodes, - required this.sourceIndex, - required this.seriesDrama, - required this.seriesEpisodes, - required this.episodeIndex, required this.prepareSource, + required this.sourceUserWantsPlay, }); - final PlayerLogic logic; - final PlaybackQueue sourceQueue; - final List sourceEpisodes; - final int sourceIndex; - final Drama seriesDrama; - final List seriesEpisodes; - final int episodeIndex; final Future Function() prepareSource; - PageController? destinationController; - VoidCallback? activateDestination; + final bool sourceUserWantsPlay; } class PlayerPage extends StatefulWidget { @@ -109,6 +94,7 @@ class PlayerPage extends StatefulWidget { this.wallet, this.downloads, this.handoff, + this.isolatePlaybackSession = false, }); final Drama drama; @@ -136,6 +122,7 @@ class PlayerPage extends StatefulWidget { final WalletController? wallet; final DownloadController? downloads; final PlayerPageHandoff? handoff; + final bool isolatePlaybackSession; @override State createState() => _PlayerPageState(); @@ -144,7 +131,6 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; - late final bool _ownsLogic; late PageController _pageController; late final PlaybackThumbnailService _thumbnailService; late final SpriteThumbnailAxisService _spriteAxisService; @@ -163,8 +149,6 @@ class _PlayerPageState extends State bool _presentationReturning = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; - PlayerPageHandoff? _activeHandoff; - PageController? _returnPageController; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -175,16 +159,13 @@ class _PlayerPageState extends State void initState() { super.initState(); final handoff = widget.handoff; - _ownsLogic = handoff == null; - logic = - handoff?.logic ?? - PlayerBinding().create( - history: context.read(), - playback: context.read(), - positionCache: context.read(), - wallet: widget.wallet, - downloads: widget.downloads, - ); + logic = PlayerBinding().create( + history: context.read(), + playback: context.read(), + positionCache: context.read(), + wallet: widget.wallet, + downloads: widget.downloads, + ); logic.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, @@ -192,22 +173,14 @@ class _PlayerPageState extends State _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); - if (handoff == null) { - logic.start( - factory: context.read(), - coordinator: context.read(), - ); - _pageController = logic.pages; - } else { - logic.adoptSeriesHandoff( - drama: handoff.seriesDrama, - seriesEpisodes: handoff.seriesEpisodes, - episodeIndex: handoff.episodeIndex, - ); - _pageController = PageController(initialPage: handoff.episodeIndex); - handoff.destinationController = _pageController; - logic.session.setRouteVisible(widget.active); - } + logic.start( + factory: context.read(), + coordinator: handoff == null && !widget.isolatePlaybackSession + ? context.read() + : PlaybackCoordinator(), + initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, + ); + _pageController = logic.pages; } @override @@ -328,11 +301,7 @@ class _PlayerPageState extends State ..removeListener(_handleSpriteFrameChanged) ..dispose(); _thumbnailService.dispose(); - if (_ownsLogic) { - logic.dispose(); - } else { - _pageController.dispose(); - } + logic.dispose(); super.dispose(); } @@ -769,11 +738,10 @@ class _PlayerPageState extends State Widget _buildPresentationReturnLayer() { final indices = logic.visibleIndices; - final controller = _returnPageController ?? _pageController; return AnimatedBuilder( animation: logic.session, builder: (context, _) => PlayerRenderStack( - pageController: controller, + pageController: _pageController, slots: _renderSlots(indices), ), ); @@ -1188,7 +1156,6 @@ class _PlayerPageState extends State final sourceIndex = logic.session.currentIndex; final sourceEpisode = logic.session.currentEpisode; final sourceQueue = logic.queue; - final sourceEpisodes = List.of(logic.sessionEpisodes); var seriesDrama = sourceQueue.entryAt(sourceIndex).drama; try { seriesDrama = await context.read().requireDetail( @@ -1217,22 +1184,17 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final sourcePageController = _pageController; + final sourceUserWantsPlay = logic.session.userWantsPlay; + try { + await logic.pauseForFullPlayer(); + } finally { + _preparingFullPlayer = false; + } + if (!mounted || !context.mounted) return; final handoff = PlayerPageHandoff( - logic: logic, - sourceQueue: sourceQueue, - sourceEpisodes: sourceEpisodes, - sourceIndex: sourceIndex, - seriesDrama: seriesDrama, - seriesEpisodes: seriesEpisodes, - episodeIndex: episodeIndex, prepareSource: _prepareSourceAfterFullPlayer, + sourceUserWantsPlay: sourceUserWantsPlay, ); - _activeHandoff = handoff; - handoff.activateDestination = () { - final destination = handoff.destinationController; - if (destination != null) logic.pages = destination; - }; _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( @@ -1251,41 +1213,24 @@ class _PlayerPageState extends State setState(() => _presentationSuspended = true); await WidgetsBinding.instance.endOfFrame; } - sourcePageController.dispose(); - handoff.activateDestination?.call(); await route; if (!mounted) return; - final currentPage = logic.visibleIndices.indexOf( - logic.session.currentIndex, - ); - final restoredPage = - _returnPageController ?? - PageController(initialPage: currentPage < 0 ? 0 : currentPage); - logic.pages = restoredPage; - _pageController = restoredPage; logic.attach(widget, isMounted: () => mounted); setState(() { _presentationActive = false; _presentationSuspended = false; _presentationReturning = false; }); - _activeHandoff = null; - logic.session.setRouteVisible(widget.active); + await logic.resumeAfterFullPlayer(); + if (!mounted) return; _logFullPlayerRoute('restored'); } Future _prepareSourceAfterFullPlayer() async { if (!mounted) return; - final handoff = _activeHandoff; - if (handoff == null) return; logic.attach(widget, isMounted: () => mounted); - logic.restoreFeedHandoff( - page: widget, - sourceQueue: handoff.sourceQueue, - sourceEpisodes: handoff.sourceEpisodes, - sourceIndex: handoff.sourceIndex, - ); - _returnPageController = PageController(initialPage: handoff.sourceIndex); + await logic.prepareFeedResume(); + if (!mounted) return; setState(() { _presentationSuspended = false; _presentationReturning = true; @@ -1296,6 +1241,7 @@ class _PlayerPageState extends State Future _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); + await logic.session.pauseForRouteHandoff(); await widget.handoff?.prepareSource(); if (context.mounted) Navigator.of(context).pop(); } diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index 7595d08..adb99f4 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,6 +32,33 @@ void main() { await session.close(); }); + test('should_keep_only_current_player_when_neighbor_warmup_disabled', () async { + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + warmNeighborsEnabled: false, + ); + + await session.select(0, preloadIndex: 1); + await session.preloadSettled; + expect(factory.created, hasLength(1)); + expect(factory.live, 1); + expect(session.preloadedIndex, isNull); + + final first = factory.created.single; + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + + expect(first.disposed, isTrue); + expect(factory.created, hasLength(2)); + expect(factory.live, 1); + expect(session.livePlayerCount, 1); + expect(session.active, same(factory.created.last)); + await session.close(); + }); + test('should_release_previous_player_immediately_when_selecting_locked', () async { final lockedEpisodes = [ Episode( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 92e4388..fa69f96 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -20,9 +20,9 @@ import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; -import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -86,7 +86,7 @@ void main() { _expectPlayerNavigationBar(tester); }); - testWidgets('should_preserve_recommend_feed_when_full_player_pops', ( + testWidgets('should_keep_feed_session_unchanged_when_switching_episode', ( tester, ) async { final factory = _RouteVideoFactory(); @@ -95,85 +95,86 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); + final feedPage = find + .descendant( + of: find.byKey(const Key('recommend-feed-player')), + matching: find.byType(PageView), + ) + .first; + await tester.drag(feedPage, const Offset(0, -620)); + await tester.pumpAndSettle(); + await tester.drag(feedPage, const Offset(0, -620)); + await tester.pumpAndSettle(); + final recommendPlayer = find.byKey(const Key('recommend-feed-player')); - final renderStack = find.descendant( - of: recommendPlayer, - matching: find.byType(PlayerRenderStack), + final sourceState = tester.state(recommendPlayer); + final sourceLogic = _pageLogic(tester, recommendPlayer); + final sourceIndex = sourceLogic.session.currentIndex; + final sourceDramaId = sourceLogic.currentDramaId; + final sourceEpisodeId = sourceLogic.session.currentEpisode.id; + final feedPlayer = sourceLogic.session.active! as _RouteVideoPlayer; + final feedPlayCalls = feedPlayer.playCalls; + expect(sourceDramaId, 'vengeance-demo'); + expect(sourceIndex, 2); + expect(sourceLogic.session.livePlayerCount, 1); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + final fullPlayer = find.byType(PlayerPage); + final fullLogic = _pageLogic(tester, fullPlayer); + expect(fullLogic.session, isNot(same(sourceLogic.session))); + expect(feedPlayer.disposed, isFalse); + expect(feedPlayer.playing, isFalse); + + for (var index = 0; index < 4; index++) { + await tester.drag( + find.byKey(const Key('player-video-tap')), + const Offset(0, -560), + ); + await tester.pumpAndSettle(); + } + + expect(fullLogic.session.currentEpisode.number, 5); + expect( + fullLogic.queue.entryAt(fullLogic.session.currentIndex).drama.id, + sourceDramaId, ); - expect(recommendPlayer, findsOneWidget); - expect(renderStack, findsOneWidget); - final playerState = tester.state(recommendPlayer); + expect(fullLogic.session.userWantsPlay, isTrue); + expect(sourceLogic.session.currentIndex, sourceIndex); + expect(sourceLogic.currentDramaId, sourceDramaId); + expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId); + expect(sourceLogic.session.active, same(feedPlayer)); + expect(feedPlayer.disposed, isFalse); + + final requestsBeforeReturn = playback.requests.length; + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + final currentSurface = find.descendant( of: recommendPlayer, matching: find.byKey(const ValueKey('player-render-slot-current')), ); - final sourceRender = tester.widget(renderStack); - final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index; - final currentEpisodeId = factory.episodes.first.id; - final controller = factory.created.first.controller; - final requestsBefore = playback.requests.length; - expect(controller.value.position, const Duration(seconds: 5)); - expect( - find.descendant( - of: currentSurface, - matching: find.byKey(const Key('player-single-placeholder')), - ), - findsNothing, - ); - - await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); - final fullPlayer = tester.widget(find.byType(PlayerPage)); - expect(fullPlayer.homeMode, isFalse); - expect(fullPlayer.handoff, isNotNull); - expect(fullPlayer.handoff!.sourceIndex, sourceIndex); - expect( - fullPlayer.handoff!.logic.session.currentIndex, - fullPlayer.handoff!.episodeIndex, - ); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); - expect(find.byKey(const Key('recommend-top-nav')), findsNothing); - expect(find.byKey(const Key('home-feed-gesture')), findsNothing); - expect(find.byKey(const Key('player-back')), findsOneWidget); - expect( - factory.episodes.where((episode) => episode.id == currentEpisodeId), - hasLength(1), - ); - expect(playback.requests.length, requestsBefore); - expect(controller.value.position, const Duration(seconds: 5)); - final fullCurrentSurface = find.descendant( - of: find.byType(PlayerPage), - matching: find.byKey(const ValueKey('player-render-slot-current')), - ); - expect( - find.descendant( - of: fullCurrentSurface, - matching: find.byKey(const Key('player-single-placeholder')), - ), - findsNothing, - ); - expect( - tester.widget(fullCurrentSurface).controller, - same(controller), - ); - - await tester.tap(find.byKey(const Key('player-back'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('recommend-page')), findsOneWidget); - expect(recommendPlayer, findsOneWidget); - expect(renderStack, findsOneWidget); - expect( - factory.episodes.where((episode) => episode.id == currentEpisodeId), - hasLength(1), - ); - expect(playback.requests.length, requestsBefore); - expect(controller.value.position, const Duration(seconds: 5)); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); - expect(tester.state(recommendPlayer), same(playerState)); - expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); + expect(tester.state(recommendPlayer), same(sourceState)); + expect(sourceLogic.session.currentIndex, sourceIndex); + expect(sourceLogic.currentDramaId, sourceDramaId); + expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId); + expect(sourceLogic.session.active, same(feedPlayer)); + expect(feedPlayer.disposed, isFalse); + expect(sourceLogic.active, isTrue); + expect(sourceLogic.session.userWantsPlay, isTrue); + expect(sourceLogic.session.canResumeAfterBackground, isTrue); + expect(sourceLogic.session.canAutoAdvance, isTrue); + expect(sourceLogic.session.isLocked, isFalse); + expect(sourceLogic.session.loading, isFalse); + expect(sourceLogic.session.activeSlotActivated, isTrue); + expect(sourceLogic.session.activeSlotPlaying, isTrue); + expect(feedPlayer.seekCalls, 1); + expect(feedPlayer.playCalls, greaterThan(feedPlayCalls)); + expect(playback.requests.length, requestsBeforeReturn); + expect(feedPlayer.controller.value.position, Duration.zero); + expect(feedPlayer.playing, isTrue); expect( find.descendant( of: currentSurface, @@ -183,72 +184,10 @@ void main() { ); expect( tester.widget(currentSurface).controller, - same(controller), - ); - }); - - testWidgets('should_swipe_full_player_without_creating_another_session', ( - tester, - ) async { - final factory = _RouteVideoFactory(); - await _pumpApp(tester, playerFactory: factory.create); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await tester.pumpAndSettle(); - - final fullPlayer = tester.widget(find.byType(PlayerPage)); - final handoff = fullPlayer.handoff!; - final dramaId = handoff.seriesDrama.id; - final firstEpisodeId = handoff.logic.session.currentEpisode.id; - final createdBefore = factory.created.length; - await tester.drag( - find.byKey(const Key('player-video-tap')), - const Offset(0, -560), - ); - await tester.pumpAndSettle(); - - expect(find.byType(PlayerPage), findsOneWidget); - expect(factory.created.length, lessThanOrEqualTo(createdBefore + 1)); - expect(find.byKey(const Key('player-render-stack')), findsOneWidget); - final switchedEpisode = handoff.logic.session.currentEpisode; - expect(switchedEpisode.id, isNot(firstEpisodeId)); - expect(switchedEpisode.number, 2); - expect( - handoff.logic.queue.entryAt(handoff.logic.session.currentIndex).drama.id, - dramaId, - ); - expect(handoff.logic.session.userWantsPlay, isTrue); - expect( - factory.episodes.where((episode) => episode.id == switchedEpisode.id), - hasLength(1), - ); - final switchedVideo = handoff.logic.session.active! as VideoPlayerPort; - final switchedController = switchedVideo.controller; - - await tester.tap(find.byKey(const Key('player-back'))); - await tester.pumpAndSettle(); - expect(handoff.logic.session.currentIndex, handoff.sourceIndex); - expect( - handoff.logic.queue.entryAt(handoff.sourceIndex).episode.id, - switchedEpisode.id, - ); - final sourceCurrentSurface = find.descendant( - of: find.byType(PlayerPage), - matching: find.byKey(const ValueKey('player-render-slot-current')), - ); - expect( - tester.widget(sourceCurrentSurface).controller, - same(switchedController), - ); - expect(switchedController.value.position, const Duration(seconds: 5)); - expect( - find.descendant( - of: sourceCurrentSurface, - matching: find.byKey(const Key('player-single-placeholder')), - ), - findsNothing, + same(feedPlayer.controller), ); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); }); testWidgets('should_show_one_paused_overlay_when_entering_full_player', ( @@ -279,7 +218,8 @@ void main() { }); testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { - await _pumpApp(tester); + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); @@ -291,33 +231,34 @@ void main() { .first; await tester.drag(feedPage, const Offset(0, -620)); await tester.pumpAndSettle(); - final sourceRender = tester.widget( - find.byType(PlayerRenderStack), - ); - final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index; - expect(sourceIndex, isNotNull); - expect(sourceIndex, greaterThan(0)); - final feedIndex = sourceIndex!; + final recommendPlayer = find.byKey(const Key('recommend-feed-player')); + final sourceLogic = _pageLogic(tester, recommendPlayer); + final feedIndex = sourceLogic.session.currentIndex; + final feedEpisodeId = sourceLogic.session.currentEpisode.id; + final feedDramaId = sourceLogic.currentDramaId; + expect(feedIndex, 1); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - final fullPlayer = tester.widget(find.byType(PlayerPage)); - final handoff = fullPlayer.handoff!; - expect(handoff.sourceIndex, feedIndex); - expect(handoff.episodeIndex, 0); - expect(handoff.logic.session.currentIndex, handoff.episodeIndex); - expect( - handoff.seriesDrama.id, - handoff.sourceQueue.entryAt(feedIndex).drama.id, - ); - expect( - handoff.logic.session.currentEpisode.id, - handoff.sourceEpisodes[feedIndex].id, + final fullLogic = _pageLogic(tester, find.byType(PlayerPage)); + await tester.drag( + find.byKey(const Key('player-video-tap')), + const Offset(0, -560), ); + await tester.pumpAndSettle(); + + expect(fullLogic.session.currentIndex, 1); + expect(fullLogic.session.currentEpisode.number, 2); + expect(fullLogic.queue.entryAt(1).drama.id, feedDramaId); + expect(sourceLogic.session.currentIndex, feedIndex); + expect(sourceLogic.session.currentEpisode.id, feedEpisodeId); + expect(sourceLogic.currentDramaId, feedDramaId); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); + expect(sourceLogic.session.currentIndex, feedIndex); + expect(sourceLogic.session.currentEpisode.id, feedEpisodeId); }); testWidgets('should_render_player_loading_indicators_at_compact_size', ( @@ -1682,6 +1623,11 @@ Future _pumpApp( if (settle) await tester.pumpAndSettle(); } +PlayerLogic _pageLogic(WidgetTester tester, Finder page) { + final dynamic state = tester.state(page); + return state.logic as PlayerLogic; +} + class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; @@ -1697,6 +1643,12 @@ class _RouteVideoFactory { class _RouteVideoPlayer extends VideoPlayerPort { _RouteVideoPlayer(super.episode); + bool disposed = false; + int seekCalls = 0; + int playCalls = 0; + + bool get playing => controller.value.isPlaying; + @override Future initialize() async { controller.value = const VideoPlayerValue( @@ -1710,6 +1662,7 @@ class _RouteVideoPlayer extends VideoPlayerPort { @override Future play() async { + playCalls++; final value = controller.value; controller.value = VideoPlayerValue( duration: value.duration, @@ -1731,6 +1684,25 @@ class _RouteVideoPlayer extends VideoPlayerPort { isPlaying: false, ); } + + @override + Future seekTo(Duration position) async { + seekCalls++; + final value = controller.value; + controller.value = VideoPlayerValue( + duration: value.duration, + size: value.size, + position: position, + isInitialized: value.isInitialized, + isPlaying: value.isPlaying, + ); + } + + @override + Future dispose() async { + disposed = true; + await super.dispose(); + } } class _RecordingPlayer implements PlayerPort { From 54eb5a60a86d1e2c6836d80168458c51ba2665bc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 16:45:00 +0800 Subject: [PATCH 18/42] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E9=A6=96=E9=A1=B5=E9=BB=91=E5=B1=8F=E5=B9=B6?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E9=A2=84=E7=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 1 - lib/core/data/services/playback_session.dart | 6 +-- .../services/playback_session_controls.dart | 8 +-- lib/core/data/services/video_player_port.dart | 8 +++ lib/pages/player/player_logic.dart | 29 ++++++----- lib/pages/player/player_view.dart | 50 ++----------------- .../player/widgets/player_video_surface.dart | 4 +- test/playback/playback_session_test.dart | 27 ---------- test/player/player_fullscreen_test.dart | 24 +++++++-- 9 files changed, 56 insertions(+), 101 deletions(-) diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index 7c86289..17d67d1 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -69,7 +69,6 @@ Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { /// 当前集已经出画后,按手势方向静默准备上一集和下一集。 void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) { - if (!session.warmNeighborsEnabled) return; final request = session._generation; final nextTask = _warmOne(session, request, session._nextIndex, next: true); final previousTask = _warmOne( diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 700648f..76d4ba6 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -27,7 +27,6 @@ class PlaybackSession extends ChangeNotifier { this.activate, PlaybackCoordinator? coordinator, this.waitForFrame, - this.warmNeighborsEnabled = true, }) : _coordinator = coordinator ?? PlaybackCoordinator(); final List episodes; @@ -40,7 +39,6 @@ class PlaybackSession extends ChangeNotifier { /// Runs only when a slot becomes current (e.g. progress listeners). final PlayerPreparation? activate; final Future Function()? waitForFrame; - final bool warmNeighborsEnabled; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; @@ -142,8 +140,8 @@ class PlaybackSession extends ChangeNotifier { } void _rememberNeighbors(int? preloadIndex, int? previousIndex) { - _nextIndex = warmNeighborsEnabled ? preloadIndex : null; - _previousIndex = warmNeighborsEnabled ? previousIndex : null; + _nextIndex = preloadIndex; + _previousIndex = previousIndex; } Future _enqueue(Future Function() operation) { diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index cf7c6e1..fbb7b10 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -6,12 +6,7 @@ part of 'playback_session.dart'; extension PlaybackSessionControls on PlaybackSession { /// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。 void prewarmNeighbors({required bool forward}) { - if (!warmNeighborsEnabled || - !_visible || - _overlayOpen || - _active?.prepared != true) { - return; - } + if (!_visible || _overlayOpen || _active?.prepared != true) return; _warmNeighbors(this, preferredNext: forward); } @@ -62,6 +57,7 @@ extension PlaybackSessionControls on PlaybackSession { slot.activated = true; } await _applyPlayback(slot); + if (_visible && !_overlayOpen && !_loading) _warmNeighbors(this); if (kDebugMode) { debugPrint( '[feed-handoff] phase=resumed activated=${slot.activated} ' diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index 2cb2b1f..032378a 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -2,6 +2,7 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:video_player/video_player.dart'; import '../../config/app_config.dart'; @@ -68,6 +69,13 @@ class VideoPlayerPort implements PlayerPort { Future dispose() async { if (_disposed) return; _disposed = true; + if (kDebugMode) { + debugPrint( + '[player-controller] phase=dispose ' + 'controller=${identityHashCode(controller)} ' + 'source=${controller.dataSource}\n${StackTrace.current}', + ); + } try { await _initialization; } catch (_) { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index cb181a8..b684845 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:flutter/scheduler.dart'; @@ -221,30 +222,36 @@ class PlayerLogic extends ChangeNotifier { /// 独立播放页覆盖首页时只暂停并保存进度,首页 session 与 controller 保持存活。 Future pauseForFullPlayer() async { if (!_started) return; + _logFeedHandoff('pause'); cancelScrub(); await session.pauseForRouteHandoff(); + _logFeedHandoff('paused'); await saveCurrentProgress(); } - /// 返回首页前把仍存活的 feed controller 定位到第 1 集起点。 - Future prepareFeedResume() async { - if (!_started) return; - await session.playbackSettled; - final player = session.active; - if (player is VideoPlayerPort && player.controller.value.isInitialized) { - await player.seekTo(Duration.zero); - } - } - /// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。 Future resumeAfterFullPlayer() async { if (!_started) return; + _logFeedHandoff('resume'); if (visibleIndices.isNotEmpty) { await session.resumeForRouteHandoff(); } else { session.setRouteVisible(false); await session.playbackSettled; } + _logFeedHandoff('resumed'); + } + + void _logFeedHandoff(String phase) { + if (!kDebugMode) return; + final player = session.active; + final video = player is VideoPlayerPort ? player : null; + debugPrint( + '[feed-session] phase=$phase session=${identityHashCode(session)} ' + 'index=${session.currentIndex} episode=${session.currentEpisode.id} ' + 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' + 'live=${session.livePlayerCount}', + ); } /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 @@ -283,7 +290,6 @@ class PlayerLogic extends ChangeNotifier { activate: activatePlayer, coordinator: coordinator, waitForFrame: _waitForFrame, - warmNeighborsEnabled: !homeMode, ); } @@ -1230,7 +1236,6 @@ class PlayerLogic extends ChangeNotifier { ), ); if (!isMounted()) return; - await prepareFeedResume(); await resumeAfterFullPlayer(); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 92a60f0..a54f0a5 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -57,12 +57,8 @@ const double _playerLoadingStrokeWidth = 2; const double _feedRefreshTopGap = 56; class PlayerPageHandoff { - PlayerPageHandoff({ - required this.prepareSource, - required this.sourceUserWantsPlay, - }); + PlayerPageHandoff({required this.sourceUserWantsPlay}); - final Future Function() prepareSource; final bool sourceUserWantsPlay; } @@ -145,8 +141,6 @@ class _PlayerPageState extends State DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; bool _presentationActive = false; - bool _presentationSuspended = false; - bool _presentationReturning = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -538,8 +532,6 @@ class _PlayerPageState extends State } Widget _buildPlayer(BuildContext context) { - if (_presentationSuspended) return const SizedBox.expand(); - if (_presentationReturning) return _buildPresentationReturnLayer(); final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; @@ -736,17 +728,6 @@ class _PlayerPageState extends State }; } - Widget _buildPresentationReturnLayer() { - final indices = logic.visibleIndices; - return AnimatedBuilder( - animation: logic.session, - builder: (context, _) => PlayerRenderStack( - pageController: _pageController, - slots: _renderSlots(indices), - ), - ); - } - PlayerRenderSlotData _renderSlot( PlayerRenderSlotRole role, int? index, @@ -1191,10 +1172,7 @@ class _PlayerPageState extends State _preparingFullPlayer = false; } if (!mounted || !context.mounted) return; - final handoff = PlayerPageHandoff( - prepareSource: _prepareSourceAfterFullPlayer, - sourceUserWantsPlay: sourceUserWantsPlay, - ); + final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay); _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( @@ -1208,41 +1186,19 @@ class _PlayerPageState extends State ), ), ); - await WidgetsBinding.instance.endOfFrame; - if (mounted) { - setState(() => _presentationSuspended = true); - await WidgetsBinding.instance.endOfFrame; - } await route; if (!mounted) return; logic.attach(widget, isMounted: () => mounted); - setState(() { - _presentationActive = false; - _presentationSuspended = false; - _presentationReturning = false; - }); + setState(() => _presentationActive = false); await logic.resumeAfterFullPlayer(); if (!mounted) return; _logFullPlayerRoute('restored'); } - Future _prepareSourceAfterFullPlayer() async { - if (!mounted) return; - logic.attach(widget, isMounted: () => mounted); - await logic.prepareFeedResume(); - if (!mounted) return; - setState(() { - _presentationSuspended = false; - _presentationReturning = true; - }); - await WidgetsBinding.instance.endOfFrame; - } - Future _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); await logic.session.pauseForRouteHandoff(); - await widget.handoff?.prepareSource(); if (context.mounted) Navigator.of(context).pop(); } diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 87884a7..29d2198 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -348,7 +348,9 @@ class _PlayerVideoSurfaceState extends State debugPrint( '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' - 'phase=$phase init=${value?.isInitialized == true ? 1 : 0}', + 'phase=$phase state=${identityHashCode(this)} ' + 'controller=${widget.controller == null ? '-' : identityHashCode(widget.controller)} ' + 'init=${value?.isInitialized == true ? 1 : 0}', ); } diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index adb99f4..7595d08 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,33 +32,6 @@ void main() { await session.close(); }); - test('should_keep_only_current_player_when_neighbor_warmup_disabled', () async { - final factory = FakeFactory(); - final session = PlaybackSession( - episodes: episodes, - factory: factory.create, - canPlay: (episode) => episode.isFree, - warmNeighborsEnabled: false, - ); - - await session.select(0, preloadIndex: 1); - await session.preloadSettled; - expect(factory.created, hasLength(1)); - expect(factory.live, 1); - expect(session.preloadedIndex, isNull); - - final first = factory.created.single; - await session.select(1, preloadIndex: 2, previousIndex: 0); - await session.preloadSettled; - - expect(first.disposed, isTrue); - expect(factory.created, hasLength(2)); - expect(factory.live, 1); - expect(session.livePlayerCount, 1); - expect(session.active, same(factory.created.last)); - await session.close(); - }); - test('should_release_previous_player_immediately_when_selecting_locked', () async { final lockedEpisodes = [ Episode( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index fa69f96..2e19255 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -109,14 +109,24 @@ void main() { final recommendPlayer = find.byKey(const Key('recommend-feed-player')); final sourceState = tester.state(recommendPlayer); final sourceLogic = _pageLogic(tester, recommendPlayer); + await sourceLogic.session.preloadSettled; + await tester.pumpAndSettle(); final sourceIndex = sourceLogic.session.currentIndex; final sourceDramaId = sourceLogic.currentDramaId; final sourceEpisodeId = sourceLogic.session.currentEpisode.id; final feedPlayer = sourceLogic.session.active! as _RouteVideoPlayer; final feedPlayCalls = feedPlayer.playCalls; + final feedPositionBefore = feedPlayer.controller.value.position; + final sourceLiveBefore = sourceLogic.session.livePlayerCount; + final sourceCurrentSurface = find.descendant( + of: recommendPlayer, + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + final sourceSurfaceState = tester.state(sourceCurrentSurface); + final sourceSurfaceElement = tester.element(sourceCurrentSurface); expect(sourceDramaId, 'vengeance-demo'); expect(sourceIndex, 2); - expect(sourceLogic.session.livePlayerCount, 1); + expect(sourceLiveBefore, greaterThan(1)); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); @@ -124,6 +134,8 @@ void main() { final fullPlayer = find.byType(PlayerPage); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); + final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; + expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); expect(feedPlayer.disposed, isFalse); expect(feedPlayer.playing, isFalse); @@ -150,6 +162,8 @@ void main() { final requestsBeforeReturn = playback.requests.length; await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); + await sourceLogic.session.preloadSettled; + await tester.pumpAndSettle(); final currentSurface = find.descendant( of: recommendPlayer, @@ -170,11 +184,15 @@ void main() { expect(sourceLogic.session.loading, isFalse); expect(sourceLogic.session.activeSlotActivated, isTrue); expect(sourceLogic.session.activeSlotPlaying, isTrue); - expect(feedPlayer.seekCalls, 1); + expect(feedPlayer.seekCalls, 0); expect(feedPlayer.playCalls, greaterThan(feedPlayCalls)); expect(playback.requests.length, requestsBeforeReturn); - expect(feedPlayer.controller.value.position, Duration.zero); + expect(feedPlayer.controller.value.position, feedPositionBefore); expect(feedPlayer.playing, isTrue); + expect(sourceLogic.session.livePlayerCount, sourceLiveBefore); + expect(sourceLogic.session.livePlayerCount, greaterThan(1)); + expect(tester.state(currentSurface), same(sourceSurfaceState)); + expect(tester.element(currentSurface), same(sourceSurfaceElement)); expect( find.descendant( of: currentSurface, From bb2c729a7e524953a1beb6aa55876806b87c97ce Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:03:19 +0800 Subject: [PATCH 19/42] =?UTF-8?q?fix(player):=20=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E8=B5=84=E6=BA=90=E6=89=80=E6=9C=89=E6=9D=83?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=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 | 14 +++++ lib/core/data/services/playback_session.dart | 51 ++++++++++++++- lib/pages/player/player_logic.dart | 5 +- lib/pages/player/player_view.dart | 49 ++++++++++++--- .../player/widgets/player_render_stack.dart | 15 ++++- .../player/widgets/player_video_surface.dart | 16 +++++ test/playback/playback_session_test.dart | 62 +++++++++++++++++++ test/player/player_fullscreen_test.dart | 24 +++++-- test/recommend/recommend_sections_test.dart | 12 ++-- 9 files changed, 223 insertions(+), 25 deletions(-) diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index 17d67d1..bed6800 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -15,6 +15,16 @@ class PlaybackCoordinator { if (!valid()) return false; if (_owner != session) { final previous = _owner; + if (previous != null && previous.owner != session.owner) { + if (kDebugMode) { + debugPrint( + '[player-owner] phase=reject-coordinator-handoff ' + 'owner=${session.owner.name} ' + 'activeOwner=${previous.owner.name}', + ); + } + return false; + } await previous?._releasePlayers(); if (previous != null && previous.livePlayerCount != 0) return false; _owner = null; @@ -56,6 +66,10 @@ Future _parkNeighbors( Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { if (slot == null || slot == session._active || slot.retiring) return; + if (slot.owner != session.owner) { + session._logOwnerRejection('park', slot); + return; + } if (session._next == null && slot.index == session._nextIndex) { session._next = slot; return; diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 76d4ba6..4637550 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -17,6 +17,8 @@ typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerPreparation = Future Function(Episode episode, PlayerPort player); +enum PlaybackSessionOwner { home, player } + /// Owns current plus previous/next frames; allocation stays serialized. class PlaybackSession extends ChangeNotifier { PlaybackSession({ @@ -27,6 +29,8 @@ class PlaybackSession extends ChangeNotifier { this.activate, PlaybackCoordinator? coordinator, this.waitForFrame, + this.owner = PlaybackSessionOwner.player, + this.dramaIdAt, }) : _coordinator = coordinator ?? PlaybackCoordinator(); final List episodes; @@ -39,6 +43,8 @@ class PlaybackSession extends ChangeNotifier { /// Runs only when a slot becomes current (e.g. progress listeners). final PlayerPreparation? activate; final Future Function()? waitForFrame; + final PlaybackSessionOwner owner; + final String? Function(int index)? dramaIdAt; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; @@ -161,7 +167,12 @@ class PlaybackSession extends ChangeNotifier { if (_live.length >= maxLivePlayers) { throw StateError('Playback resource limit reached'); } - final slot = _PlayerSlot(index, factory(episodes[index])); + final slot = _PlayerSlot( + index, + factory(episodes[index]), + owner: owner, + dramaId: dramaIdAt?.call(index), + ); _live.add(slot); slot.ready = () async { try { @@ -180,7 +191,19 @@ class PlaybackSession extends ChangeNotifier { } Future _retire(_PlayerSlot? slot) async { - if (slot == null || !_live.contains(slot)) return; + if (slot == null) return; + if (slot.owner != owner) { + _logOwnerRejection('retire', slot); + return; + } + if (!_live.contains(slot)) return; + if (kDebugMode) { + debugPrint( + '[player-owner] phase=retire owner=${slot.owner.name} ' + 'drama=${slot.dramaId ?? '-'} episodeIndex=${slot.index} ' + 'controller=${_playerIdentity(slot.player)}', + ); + } slot.retiring = true; final cleared = _active == slot || @@ -460,6 +483,15 @@ class PlaybackSession extends ChangeNotifier { if (!_closed) notifyListeners(); } + void _logOwnerRejection(String phase, _PlayerSlot slot) { + if (!kDebugMode) return; + debugPrint( + '[player-owner] phase=reject-$phase expected=${owner.name} ' + 'actual=${slot.owner.name} drama=${slot.dramaId ?? '-'} ' + 'episodeIndex=${slot.index} controller=${_playerIdentity(slot.player)}', + ); + } + Future close() async { if (_closed) return; _closed = true; @@ -470,9 +502,16 @@ class PlaybackSession extends ChangeNotifier { } class _PlayerSlot { - _PlayerSlot(this.index, this.player); + _PlayerSlot( + this.index, + this.player, { + required this.owner, + required this.dramaId, + }); int index; final PlayerPort player; + final PlaybackSessionOwner owner; + final String? dramaId; late final Future ready; bool prepared = false; bool activated = false; @@ -481,3 +520,9 @@ class _PlayerSlot { bool reusedWarm = false; Object? failure; } + +String _playerIdentity(PlayerPort player) { + return player is VideoPlayerPort + ? '${identityHashCode(player.controller)}' + : '${identityHashCode(player)}'; +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index b684845..69dfcb0 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -290,6 +290,10 @@ class PlayerLogic extends ChangeNotifier { activate: activatePlayer, coordinator: coordinator, waitForFrame: _waitForFrame, + owner: homeMode ? PlaybackSessionOwner.home : PlaybackSessionOwner.player, + dramaIdAt: (index) => index >= 0 && index < queue.length + ? queue.entryAt(index).drama.id + : null, ); } @@ -1231,7 +1235,6 @@ class PlayerLogic extends ChangeNotifier { initialIndex: index, wallet: wallet, downloads: downloads, - isolatePlaybackSession: true, ), ), ); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a54f0a5..fd5b85f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -90,7 +90,7 @@ class PlayerPage extends StatefulWidget { this.wallet, this.downloads, this.handoff, - this.isolatePlaybackSession = false, + this.revealAfterFirstFrame = false, }); final Drama drama; @@ -118,7 +118,7 @@ class PlayerPage extends StatefulWidget { final WalletController? wallet; final DownloadController? downloads; final PlayerPageHandoff? handoff; - final bool isolatePlaybackSession; + final bool revealAfterFirstFrame; @override State createState() => _PlayerPageState(); @@ -141,6 +141,7 @@ class _PlayerPageState extends State DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; bool _presentationActive = false; + late bool _firstFrameReady; bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -152,6 +153,7 @@ class _PlayerPageState extends State @override void initState() { super.initState(); + _firstFrameReady = !widget.revealAfterFirstFrame; final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), @@ -169,12 +171,18 @@ class _PlayerPageState extends State WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), - coordinator: handoff == null && !widget.isolatePlaybackSession + coordinator: widget.embedded ? context.read() : PlaybackCoordinator(), initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, ); _pageController = logic.pages; + if (kDebugMode && widget.revealAfterFirstFrame) { + debugPrint( + '[player-entry] phase=waiting-first-frame ' + 'session=${identityHashCode(logic.session)}', + ); + } } @override @@ -594,6 +602,7 @@ class _PlayerPageState extends State PlayerRenderStack( pageController: _pageController, slots: _renderSlots(indices), + onCurrentVideoVisible: _handleCurrentVideoVisible, ), pageLayer, ], @@ -660,16 +669,37 @@ class _PlayerPageState extends State ), ), ); - if (widget.handoff == null) return player; + final revealContent = + !widget.revealAfterFirstFrame || + _firstFrameReady || + logic.session.isLocked || + logic.ticketError != null || + logic.blockedEpisodeId != null || + logic.session.error != null; + final presentedPlayer = widget.revealAfterFirstFrame + ? Opacity(opacity: revealContent ? 1 : 0, child: player) + : player; + if (widget.handoff == null) return presentedPlayer; return PopScope( canPop: _leavingHandoff, onPopInvokedWithResult: (didPop, _) { if (!didPop) unawaited(_leaveHandoff(context)); }, - child: player, + child: presentedPlayer, ); } + void _handleCurrentVideoVisible() { + if (!mounted || _firstFrameReady) return; + setState(() => _firstFrameReady = true); + if (kDebugMode) { + debugPrint( + '[player-entry] phase=ready ' + 'session=${identityHashCode(logic.session)}', + ); + } + } + void _openCurrentMore() { final entry = logic.queue.entryAt(logic.session.currentIndex); _openMore(context, entry.episode); @@ -1176,13 +1206,18 @@ class _PlayerPageState extends State _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage( + PageRouteBuilder( + opaque: false, + barrierDismissible: false, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => PlayerPage( drama: seriesDrama, initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, + revealAfterFirstFrame: true, ), ), ); diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 2f4dc29..fc19e0a 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -52,10 +52,12 @@ class PlayerRenderStack extends StatefulWidget { super.key, required this.pageController, required this.slots, + this.onCurrentVideoVisible, }); final PageController pageController; final Map slots; + final VoidCallback? onCurrentVideoVisible; @override State createState() => _PlayerRenderStackState(); @@ -68,7 +70,10 @@ class _PlayerRenderStackState extends State { void initState() { super.initState(); _lastCurrentIndex = _currentSlot.index; - _log('init current=${_lastCurrentIndex ?? '-'}'); + _log( + 'init current=${_lastCurrentIndex ?? '-'} ' + 'pageController=${identityHashCode(widget.pageController)}', + ); } @override @@ -86,7 +91,10 @@ class _PlayerRenderStackState extends State { @override void dispose() { - _log('dispose current=${_lastCurrentIndex ?? '-'}'); + _log( + 'dispose current=${_lastCurrentIndex ?? '-'} ' + 'pageController=${identityHashCode(widget.pageController)}', + ); super.dispose(); } @@ -189,6 +197,9 @@ class _PlayerRenderStackState extends State { fallback: slot.fallback, warmSlotReused: slot.warmSlotReused, waitForPlaybackFrame: slot.waitForPlaybackFrame, + onVideoVisible: slot.role == PlayerRenderSlotRole.current + ? widget.onCurrentVideoVisible + : null, ), ), ); diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 29d2198..49f52f6 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -28,6 +28,7 @@ class PlayerVideoSurface extends StatefulWidget { this.debugSlot = 'default', this.debugIndex, this.showFallbackBackground = true, + this.onVideoVisible, }); final VideoPlayerController? controller; @@ -57,6 +58,7 @@ class PlayerVideoSurface extends StatefulWidget { final bool showFallbackBackground; final String videoUrl; final bool theater; + final VoidCallback? onVideoVisible; @override State createState() => _PlayerVideoSurfaceState(); @@ -73,6 +75,7 @@ class _PlayerVideoSurfaceState extends State bool _confirmationScheduled = false; bool _transitioning = false; bool _videoVisible = false; + bool _videoVisibleNotified = false; String? _lastLayerState; @override @@ -279,6 +282,7 @@ class _PlayerVideoSurfaceState extends State }); } _logLayer('video', widget.controller?.value); + _notifyVideoVisible(); return; } if (initial) { @@ -303,6 +307,7 @@ class _PlayerVideoSurfaceState extends State _placeholderSnapshot = null; }); _logLayer('fade-end', widget.controller?.value); + _notifyVideoVisible(); } void _resetForControllerChange() { @@ -314,9 +319,20 @@ class _PlayerVideoSurfaceState extends State _confirmationScheduled = false; _transitioning = false; _videoVisible = false; + _videoVisibleNotified = false; _lastLayerState = null; } + void _notifyVideoVisible() { + if (_videoVisibleNotified) return; + _videoVisibleNotified = true; + final callback = widget.onVideoVisible; + if (callback == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) callback(); + }); + } + void _logLayer(String phase, VideoPlayerValue? value) { if (!kDebugMode) return; final fallback = phase == 'video' || phase == 'fade-end' diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index 7595d08..a62f69c 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,6 +32,68 @@ void main() { await session.close(); }); + test('should_not_retire_home_controller_when_player_switches_episodes', () async { + final factory = FakeFactory(); + final home = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.home, + ); + await home.select(0, preloadIndex: 1); + await home.preloadSettled; + final homePlayer = home.active; + expect(homePlayer, isNotNull); + + final player = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.player, + ); + await player.select(0, preloadIndex: 1); + await player.select(1, previousIndex: 0); + await player.preloadSettled; + + expect(home.active, same(homePlayer)); + expect( + factory.created + .firstWhere((candidate) => identical(candidate, homePlayer)) + .disposed, + isFalse, + ); + expect(player.active, isNot(same(homePlayer))); + await player.close(); + await home.close(); + }); + + test('should_keep_home_controller_alive_when_player_session_closes', () async { + final factory = FakeFactory(); + final home = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.home, + ); + await home.select(0); + final homePlayer = home.active! as FakePlayer; + + final player = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.player, + ); + await player.select(0); + await player.select(1); + await player.close(); + + expect(homePlayer.disposed, isFalse); + expect(home.active, same(homePlayer)); + expect(home.currentIndex, 0); + await home.close(); + }); + test('should_release_previous_player_immediately_when_selecting_locked', () async { final lockedEpisodes = [ Episode( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 2e19255..131e698 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -131,7 +131,7 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - final fullPlayer = find.byType(PlayerPage); + final fullPlayer = _fullPlayerFinder(); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; @@ -223,11 +223,17 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); + expect(_fullPlayerFinder(), findsOneWidget); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); - expect(find.byKey(const Key('recommend-top-nav')), findsNothing); + expect( + find.byKey(const Key('home-feed-center-play')).hitTestable(), + findsNothing, + ); + expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); + expect( + find.byKey(const Key('recommend-top-nav')).hitTestable(), + findsNothing, + ); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); @@ -259,7 +265,7 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - final fullLogic = _pageLogic(tester, find.byType(PlayerPage)); + final fullLogic = _pageLogic(tester, _fullPlayerFinder()); await tester.drag( find.byKey(const Key('player-video-tap')), const Offset(0, -560), @@ -1646,6 +1652,12 @@ PlayerLogic _pageLogic(WidgetTester tester, Finder page) { return state.logic as PlayerLogic; } +Finder _fullPlayerFinder() { + return find.byWidgetPredicate( + (widget) => widget is PlayerPage && widget.handoff != null, + ); +} + class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index a85104d..c39412e 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -471,13 +471,13 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byType(PlayerPage).last, findsOneWidget); expect( - tester.widget(find.byType(PlayerPage)).homeMode, + tester.widget(find.byType(PlayerPage).last).homeMode, isFalse, ); expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); @@ -485,13 +485,13 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byType(PlayerPage).last, findsOneWidget); expect( - tester.widget(find.byType(PlayerPage)).homeMode, + tester.widget(find.byType(PlayerPage).last).homeMode, isFalse, ); expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); }); From 4d3d067f77cfebdbf4f1dfa9d54ef7092707a88b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:13:56 +0800 Subject: [PATCH 20/42] =?UTF-8?q?fix(player):=20=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E9=A2=84=E7=83=AD=E5=9B=9E=E6=94=B6=E6=97=A5=E5=BF=97=E5=B9=B6?= =?UTF-8?q?=E6=8A=93=E5=B8=A7=E8=BF=87=E6=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 26 +++-- lib/core/data/services/playback_session.dart | 64 +++++++++--- lib/core/data/services/video_player_port.dart | 8 -- lib/pages/player/player_view.dart | 98 +++++++++++++++++-- test/player/player_fullscreen_test.dart | 17 ++++ test/recommend/recommend_sections_test.dart | 13 +++ 6 files changed, 186 insertions(+), 40 deletions(-) diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index bed6800..2d54d46 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -59,12 +59,16 @@ Future _parkNeighbors( _PlayerSlot? warmNext, _PlayerSlot? warmPrevious, ) async { - await _placeNeighbor(session, old); - await _placeNeighbor(session, warmNext); - await _placeNeighbor(session, warmPrevious); + await _placeNeighbor(session, old, roleHint: 'old-current'); + await _placeNeighbor(session, warmNext, roleHint: 'next'); + await _placeNeighbor(session, warmPrevious, roleHint: 'previous'); } -Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { +Future _placeNeighbor( + PlaybackSession session, + _PlayerSlot? slot, { + required String roleHint, +}) async { if (slot == null || slot == session._active || slot.retiring) return; if (slot.owner != session.owner) { session._logOwnerRejection('park', slot); @@ -78,7 +82,7 @@ Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { session._previous = slot; return; } - await session._retire(slot); + await session._retire(slot, trigger: 'park-neighbor', roleHint: roleHint); } /// 当前集已经出画后,按手势方向静默准备上一集和下一集。 @@ -118,7 +122,11 @@ Future _warmOne( await session._enqueue(() async { if (!_stillHeld(session, candidate, next: next)) return; _clearNeighbor(session, next: next); - await session._retire(candidate); + await session._retire( + candidate, + trigger: 'warm-failure', + roleHint: next ? 'next' : 'previous', + ); }); } @@ -135,7 +143,11 @@ Future<_PlayerSlot?> _allocateNeighbor( final current = next ? session._next : session._previous; if (!allowed || current?.index != index) { _clearNeighbor(session, next: next); - await session._retire(current); + await session._retire( + current, + trigger: 'warm-slot-invalid', + roleHint: next ? 'next' : 'previous', + ); } if (!_canAllocate(session, request, allowed: allowed, next: next)) { return; diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 4637550..205a6cd 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -138,8 +138,12 @@ class PlaybackSession extends ChangeNotifier { _notify(); unawaited( _enqueue(() async { - await _retire(staleNext); - await _retire(stalePrevious); + await _retire(staleNext, trigger: 'remap-stale-next', roleHint: 'next'); + await _retire( + stalePrevious, + trigger: 'remap-stale-previous', + roleHint: 'previous', + ); }), ); if (_visible && !_overlayOpen) _warmNeighbors(this); @@ -190,20 +194,23 @@ class PlaybackSession extends ChangeNotifier { return slot; } - Future _retire(_PlayerSlot? slot) async { + Future _retire( + _PlayerSlot? slot, { + required String trigger, + String? roleHint, + }) async { if (slot == null) return; if (slot.owner != owner) { _logOwnerRejection('retire', slot); return; } if (!_live.contains(slot)) return; - if (kDebugMode) { - debugPrint( - '[player-owner] phase=retire owner=${slot.owner.name} ' - 'drama=${slot.dramaId ?? '-'} episodeIndex=${slot.index} ' - 'controller=${_playerIdentity(slot.player)}', - ); - } + final role = _slotRole(slot, roleHint: roleHint); + final video = slot.player is VideoPlayerPort + ? slot.player as VideoPlayerPort + : null; + final value = video?.controller.value; + final slotPlaying = slot.playing; slot.retiring = true; final cleared = _active == slot || @@ -222,6 +229,19 @@ class PlaybackSession extends ChangeNotifier { } finally { await slot.player.dispose(); _live.remove(slot); + if (kDebugMode) { + debugPrint( + '[player-slot] slot_released role=$role owner=${slot.owner.name} ' + 'selectOwner=$owner drama=${slot.dramaId ?? '-'} ' + 'episodeIndex=${slot.index} ' + 'controller=${_playerIdentity(slot.player)} ' + 'slotPlaying=${slotPlaying ? 1 : 0} ' + 'isPlaying=${value?.isPlaying == true ? 1 : 0} ' + 'position=${value?.position.inMilliseconds ?? '-'} ' + 'currentIndex=$_currentIndex generation=$_generation ' + 'trigger=$trigger', + ); + } } } @@ -268,7 +288,7 @@ class PlaybackSession extends ChangeNotifier { _PlayerSlot? target; await _enqueue(() async { if (_closed || request != _generation) return; - await _retire(_handoff); + await _retire(_handoff, trigger: 'select-handoff'); final old = _active; if (old != null && old.index == index && @@ -339,7 +359,9 @@ class PlaybackSession extends ChangeNotifier { await _enqueue(() => _start(slot, request)); await Future.delayed(_qualityHandoffHold); if (previous != null && _handoff == previous && _active == slot) { - await _enqueue(() => _retire(previous)); + await _enqueue( + () => _retire(previous, trigger: 'quality-handoff-complete'), + ); } if (!_closed && request == _generation) _warmNeighbors(this); return _active == slot && slot.failure == null; @@ -351,8 +373,8 @@ class PlaybackSession extends ChangeNotifier { if (_closed || request != _generation || _active == null) return; final warm = _next; _next = null; - await _retire(_handoff); - await _retire(warm); + await _retire(_handoff, trigger: 'replacement-handoff'); + await _retire(warm, trigger: 'replacement-next'); if (_closed || request != _generation || _active == null) return; try { created = _create(index); @@ -379,7 +401,9 @@ class PlaybackSession extends ChangeNotifier { Future _dropReplacement(_PlayerSlot slot) { return _enqueue(() async { - if (_live.contains(slot)) await _retire(slot); + if (_live.contains(slot)) { + await _retire(slot, trigger: 'replacement-drop'); + } }); } @@ -474,7 +498,7 @@ class PlaybackSession extends ChangeNotifier { _loading = false; _notify(); for (final slot in _live.toList()) { - await _retire(slot); + await _retire(slot, trigger: 'session-release-all'); } }); } @@ -483,6 +507,14 @@ class PlaybackSession extends ChangeNotifier { if (!_closed) notifyListeners(); } + String _slotRole(_PlayerSlot slot, {String? roleHint}) { + if (identical(slot, _active)) return 'current'; + if (identical(slot, _previous)) return 'previous'; + if (identical(slot, _next)) return 'next'; + if (identical(slot, _handoff)) return 'handoff'; + return roleHint ?? 'unassigned'; + } + void _logOwnerRejection(String phase, _PlayerSlot slot) { if (!kDebugMode) return; debugPrint( diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index 032378a..2cb2b1f 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -2,7 +2,6 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:video_player/video_player.dart'; import '../../config/app_config.dart'; @@ -69,13 +68,6 @@ class VideoPlayerPort implements PlayerPort { Future dispose() async { if (_disposed) return; _disposed = true; - if (kDebugMode) { - debugPrint( - '[player-controller] phase=dispose ' - 'controller=${identityHashCode(controller)} ' - 'source=${controller.dataSource}\n${StackTrace.current}', - ); - } try { await _initialization; } catch (_) { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index fd5b85f..451c720 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1,9 +1,11 @@ // Author: fruit.com.cn import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -91,6 +93,7 @@ class PlayerPage extends StatefulWidget { this.downloads, this.handoff, this.revealAfterFirstFrame = false, + this.initialFrameBytes, }); final Drama drama; @@ -119,6 +122,7 @@ class PlayerPage extends StatefulWidget { final DownloadController? downloads; final PlayerPageHandoff? handoff; final bool revealAfterFirstFrame; + final Uint8List? initialFrameBytes; @override State createState() => _PlayerPageState(); @@ -142,6 +146,7 @@ class _PlayerPageState extends State bool _swipePrewarmCaptured = false; bool _presentationActive = false; late bool _firstFrameReady; + final GlobalKey _renderBoundaryKey = GlobalKey(); bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -599,10 +604,13 @@ class _PlayerPageState extends State return Stack( fit: StackFit.expand, children: [ - PlayerRenderStack( - pageController: _pageController, - slots: _renderSlots(indices), - onCurrentVideoVisible: _handleCurrentVideoVisible, + RepaintBoundary( + key: _renderBoundaryKey, + child: PlayerRenderStack( + pageController: _pageController, + slots: _renderSlots(indices), + onCurrentVideoVisible: _handleCurrentVideoVisible, + ), ), pageLayer, ], @@ -677,7 +685,25 @@ class _PlayerPageState extends State logic.blockedEpisodeId != null || logic.session.error != null; final presentedPlayer = widget.revealAfterFirstFrame - ? Opacity(opacity: revealContent ? 1 : 0, child: player) + ? Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: revealContent + ? const SizedBox.expand() + : widget.initialFrameBytes == null + ? _buildInitialFrameFallback() + : Image.memory( + widget.initialFrameBytes!, + key: const Key('player-entry-frame'), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _buildInitialFrameFallback(), + ), + ), + Opacity(opacity: revealContent ? 1 : 0, child: player), + ], + ) : player; if (widget.handoff == null) return presentedPlayer; return PopScope( @@ -700,6 +726,24 @@ class _PlayerPageState extends State } } + Widget _buildInitialFrameFallback() { + final index = widget.initialIndex; + if (index < 0 || index >= widget.drama.episodes.length) { + return const ColoredBox( + key: Key('player-entry-black'), + color: AppColors.mediaBlack, + ); + } + final episode = widget.drama.episodes[index]; + return playerOpeningFrame( + episode.thumbnailsUrl, + posterUrl: episode.posterUrl, + spriteSource: _spriteAxisService, + fit: BoxFit.cover, + ) ?? + const ColoredBox(color: AppColors.mediaBlack); + } + void _openCurrentMore() { final entry = logic.queue.entryAt(logic.session.currentIndex); _openMore(context, entry.episode); @@ -1195,6 +1239,8 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } + final initialFrameBytes = await _captureCurrentFrame(); + if (!mounted || !context.mounted) return; final sourceUserWantsPlay = logic.session.userWantsPlay; try { await logic.pauseForFullPlayer(); @@ -1207,17 +1253,20 @@ class _PlayerPageState extends State setState(() => _presentationActive = true); final route = Navigator.of(context).push( PageRouteBuilder( - opaque: false, + opaque: true, barrierDismissible: false, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, + transitionDuration: const Duration(milliseconds: 180), + reverseTransitionDuration: const Duration(milliseconds: 180), + transitionsBuilder: (_, animation, _, child) => + FadeTransition(opacity: animation, child: child), pageBuilder: (_, _, _) => PlayerPage( drama: seriesDrama, initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, - revealAfterFirstFrame: true, + revealAfterFirstFrame: initialFrameBytes != null, + initialFrameBytes: initialFrameBytes, ), ), ); @@ -1230,6 +1279,37 @@ class _PlayerPageState extends State _logFullPlayerRoute('restored'); } + Future _captureCurrentFrame() async { + final boundary = _renderBoundaryKey.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary) return null; + final startedAt = DateTime.now(); + try { + final pixelRatio = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 2.0).toDouble(); + final image = await boundary.toImage(pixelRatio: pixelRatio); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + Uint8List? bytes; + if (data != null) { + bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + } + if (kDebugMode) { + debugPrint( + '[player-entry] phase=frame-captured ' + 'bytes=${bytes?.length ?? 0} ' + 'ms=${DateTime.now().difference(startedAt).inMilliseconds}', + ); + } + return bytes; + } catch (error) { + if (kDebugMode) { + debugPrint('[player-entry] phase=frame-capture-failed error=$error'); + } + return null; + } + } + Future _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 131e698..62d2105 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -129,11 +129,15 @@ void main() { expect(sourceLiveBefore, greaterThan(1)); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); final fullPlayer = _fullPlayerFinder(); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); + final fullPlayerWidget = tester.widget(fullPlayer); + expect(fullPlayerWidget.initialFrameBytes, isNotNull); + expect(fullPlayerWidget.revealAfterFirstFrame, isTrue); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); expect(feedPlayer.disposed, isFalse); @@ -221,6 +225,7 @@ void main() { expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); expect(_fullPlayerFinder(), findsOneWidget); @@ -263,6 +268,7 @@ void main() { expect(feedIndex, 1); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); final fullLogic = _pageLogic(tester, _fullPlayerFinder()); @@ -1658,6 +1664,17 @@ Finder _fullPlayerFinder() { ); } +Future _waitForFullPlayerEntry(WidgetTester tester) async { + for (var attempt = 0; attempt < 120; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 16)); + if (_fullPlayerFinder().evaluate().isNotEmpty) return; + } + fail('full player route was not pushed after frame capture'); +} + class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index c39412e..3f7e221 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -470,6 +470,7 @@ void main() { await _pumpRecommend(tester); await tester.tap(find.byKey(const Key('home-feed-series-name'))); + await _waitForFullPlayer(tester); await tester.pumpAndSettle(); expect(find.byType(PlayerPage).last, findsOneWidget); expect( @@ -484,6 +485,7 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayer(tester); await tester.pumpAndSettle(); expect(find.byType(PlayerPage).last, findsOneWidget); expect( @@ -550,6 +552,17 @@ void main() { }); } +Future _waitForFullPlayer(WidgetTester tester) async { + for (var attempt = 0; attempt < 120; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 16)); + if (find.byType(PlayerPage).evaluate().length > 1) return; + } + fail('full player route was not pushed after frame capture'); +} + Future _pumpRecommend(WidgetTester tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; From 55fc2796a987c12a112de0f7cc3d648eeef9e1a9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:20:24 +0800 Subject: [PATCH 21/42] =?UTF-8?q?fix(player):=20=E8=A7=82=E7=9C=8B?= =?UTF-8?q?=E5=85=A8=E9=9B=86=E5=A4=8D=E7=94=A8=E6=8A=93=E5=B8=A7=E9=A6=96?= =?UTF-8?q?=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_frame_capture.dart | 63 ++++++++++++++++++++++ lib/pages/player/player_logic.dart | 12 +++++ lib/pages/player/player_view.dart | 51 ++++++------------ test/player/player_fullscreen_test.dart | 46 ++++++++++++++++ 4 files changed, 136 insertions(+), 36 deletions(-) create mode 100644 lib/pages/player/player_frame_capture.dart diff --git a/lib/pages/player/player_frame_capture.dart b/lib/pages/player/player_frame_capture.dart new file mode 100644 index 0000000..bd83025 --- /dev/null +++ b/lib/pages/player/player_frame_capture.dart @@ -0,0 +1,63 @@ +// Author: fruit.com.cn + +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class PlayerFrameCapture { + const PlayerFrameCapture._(); + + static const int budgetMilliseconds = 32; + + static Future capture({ + required BuildContext context, + required GlobalKey boundaryKey, + required String source, + }) async { + final boundary = boundaryKey.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary) { + _log('[player-entry] phase=frame-capture-unavailable source=$source'); + return null; + } + final stopwatch = Stopwatch()..start(); + try { + final pixelRatio = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 2.0).toDouble(); + final image = await boundary.toImage(pixelRatio: pixelRatio); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (stopwatch.elapsedMilliseconds > budgetMilliseconds) { + _log( + '[player-entry] phase=frame-capture-over-budget source=$source ' + 'ms=${stopwatch.elapsedMilliseconds} budget_ms=$budgetMilliseconds', + ); + } + if (data == null) { + _log('[player-entry] phase=frame-capture-empty source=$source'); + return null; + } + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + _log( + '[player-entry] phase=frame-captured source=$source ' + 'bytes=${bytes.length} ms=${stopwatch.elapsedMilliseconds}', + ); + return bytes; + } catch (error) { + _log( + '[player-entry] phase=frame-capture-failed source=$source ' + 'error=$error', + ); + return null; + } + } + + static void _log(String message) { + if (kDebugMode) debugPrint(message); + } +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 69dfcb0..fea8fb1 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -32,6 +32,7 @@ import '../discover/search_page.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; +import 'player_frame_capture.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; import 'widgets/episode_picker_sheet.dart'; @@ -64,6 +65,9 @@ class PlayerLogic extends ChangeNotifier { final Future Function() _waitForFrame; late PageController pages; + final GlobalKey renderBoundaryKey = GlobalKey( + debugLabel: 'player-render-boundary', + ); late PlaybackQueue queue; late List visibleIndices; late PlaybackSession session; @@ -1226,6 +1230,12 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { + final initialFrameBytes = await PlayerFrameCapture.capture( + context: context, + boundaryKey: renderBoundaryKey, + source: 'watch-all', + ); + if (!context.mounted) return; await pauseForFullPlayer(); if (!context.mounted) return; await Navigator.of(context).push( @@ -1235,6 +1245,8 @@ class PlayerLogic extends ChangeNotifier { initialIndex: index, wallet: wallet, downloads: downloads, + revealAfterFirstFrame: initialFrameBytes != null, + initialFrameBytes: initialFrameBytes, ), ), ); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 451c720..e59148f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1,11 +1,9 @@ // Author: fruit.com.cn import 'dart:async'; -import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -34,6 +32,7 @@ import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.dart'; import 'comments_auth.dart'; import 'player_binding.dart'; +import 'player_frame_capture.dart'; import 'player_logic.dart'; import 'widgets/home_feed_header.dart'; import 'widgets/home_feed_overlay.dart'; @@ -146,7 +145,6 @@ class _PlayerPageState extends State bool _swipePrewarmCaptured = false; bool _presentationActive = false; late bool _firstFrameReady; - final GlobalKey _renderBoundaryKey = GlobalKey(); bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -159,6 +157,14 @@ class _PlayerPageState extends State void initState() { super.initState(); _firstFrameReady = !widget.revealAfterFirstFrame; + if (kDebugMode && + widget.revealAfterFirstFrame && + widget.initialFrameBytes != null) { + debugPrint( + '[player-entry] phase=initial-frame-shown ' + 'bytes=${widget.initialFrameBytes!.length}', + ); + } final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), @@ -605,7 +611,7 @@ class _PlayerPageState extends State fit: StackFit.expand, children: [ RepaintBoundary( - key: _renderBoundaryKey, + key: logic.renderBoundaryKey, child: PlayerRenderStack( pageController: _pageController, slots: _renderSlots(indices), @@ -1239,7 +1245,11 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final initialFrameBytes = await _captureCurrentFrame(); + final initialFrameBytes = await PlayerFrameCapture.capture( + context: context, + boundaryKey: logic.renderBoundaryKey, + source: 'full-player', + ); if (!mounted || !context.mounted) return; final sourceUserWantsPlay = logic.session.userWantsPlay; try { @@ -1279,37 +1289,6 @@ class _PlayerPageState extends State _logFullPlayerRoute('restored'); } - Future _captureCurrentFrame() async { - final boundary = _renderBoundaryKey.currentContext?.findRenderObject(); - if (boundary is! RenderRepaintBoundary) return null; - final startedAt = DateTime.now(); - try { - final pixelRatio = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 2.0).toDouble(); - final image = await boundary.toImage(pixelRatio: pixelRatio); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - Uint8List? bytes; - if (data != null) { - bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - } - if (kDebugMode) { - debugPrint( - '[player-entry] phase=frame-captured ' - 'bytes=${bytes?.length ?? 0} ' - 'ms=${DateTime.now().difference(startedAt).inMilliseconds}', - ); - } - return bytes; - } catch (error) { - if (kDebugMode) { - debugPrint('[player-entry] phase=frame-capture-failed error=$error'); - } - return null; - } - } - Future _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 62d2105..3db6c3c 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -246,6 +246,34 @@ void main() { expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); + testWidgets('should_capture_feed_frame_when_opening_series_from_picker', ( + tester, + ) async { + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-episodes'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-episode-2'))); + await _waitForRevealedPlayerEntry(tester); + await tester.pumpAndSettle(); + + final player = find.byWidgetPredicate( + (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, + ); + expect(player, findsOneWidget); + final playerWidget = tester.widget(player); + expect(playerWidget.initialFrameBytes, isNotNull); + expect(playerWidget.revealAfterFirstFrame, isTrue); + expect(playerWidget.initialIndex, 1); + + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + }); + testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { final factory = _RouteVideoFactory(); await _pumpApp(tester, playerFactory: factory.create); @@ -1675,6 +1703,24 @@ Future _waitForFullPlayerEntry(WidgetTester tester) async { fail('full player route was not pushed after frame capture'); } +Future _waitForRevealedPlayerEntry(WidgetTester tester) async { + for (var attempt = 0; attempt < 120; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 16)); + if (find + .byWidgetPredicate( + (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, + ) + .evaluate() + .isNotEmpty) { + return; + } + } + fail('revealed player route was not pushed after frame capture'); +} + class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; From 1fc8c4924e3c31bc12c65ad9fc0f42e37ee8ff42 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:21:52 +0800 Subject: [PATCH 22/42] =?UTF-8?q?fix(player):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E8=A7=82=E7=9C=8B=E5=85=A8=E9=9B=86=E6=B7=A1=E5=85=A5=E6=97=B6?= =?UTF-8?q?=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/navigation/fade_page_route.dart | 22 ++++++++++++---------- lib/pages/player/player_logic.dart | 1 + 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/core/navigation/fade_page_route.dart b/lib/core/navigation/fade_page_route.dart index 8b1c0fd..dce3e01 100644 --- a/lib/core/navigation/fade_page_route.dart +++ b/lib/core/navigation/fade_page_route.dart @@ -4,14 +4,16 @@ import 'package:flutter/widgets.dart'; /// 无位移的短促淡入转场。 class FadePageRoute extends PageRouteBuilder { - FadePageRoute({required WidgetBuilder builder, super.settings}) - : super( - transitionDuration: const Duration(milliseconds: 220), - reverseTransitionDuration: const Duration(milliseconds: 180), - pageBuilder: (context, _, _) => builder(context), - transitionsBuilder: (context, animation, _, child) => FadeTransition( - opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), - child: child, - ), - ); + FadePageRoute({ + required WidgetBuilder builder, + super.settings, + super.transitionDuration = const Duration(milliseconds: 220), + super.reverseTransitionDuration = const Duration(milliseconds: 180), + }) : super( + pageBuilder: (context, _, _) => builder(context), + transitionsBuilder: (context, animation, _, child) => FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index fea8fb1..f772ad1 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -1240,6 +1240,7 @@ class PlayerLogic extends ChangeNotifier { if (!context.mounted) return; await Navigator.of(context).push( FadePageRoute( + transitionDuration: const Duration(milliseconds: 180), builder: (_) => PlayerPage( drama: drama, initialIndex: index, From c8ec481fc92de4bb6147c02a4ddf73c614a70b2b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:27:43 +0800 Subject: [PATCH 23/42] =?UTF-8?q?fix(player):=20=E9=A2=84=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E8=A7=82=E7=9C=8B=E5=85=A8=E9=9B=86=E9=A6=96=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_frame_capture.dart | 71 +++++++++++++++++++++ lib/pages/player/player_logic.dart | 22 +++++-- lib/pages/player/player_view.dart | 57 ++++++++++++----- test/player/player_fullscreen_test.dart | 12 +++- test/recommend/recommend_sections_test.dart | 9 ++- 5 files changed, 145 insertions(+), 26 deletions(-) diff --git a/lib/pages/player/player_frame_capture.dart b/lib/pages/player/player_frame_capture.dart index bd83025..41e7b18 100644 --- a/lib/pages/player/player_frame_capture.dart +++ b/lib/pages/player/player_frame_capture.dart @@ -6,11 +6,56 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import '../../core/config/app_config.dart'; + +class PreparedPlayerFrame { + const PreparedPlayerFrame({this.bytes, this.provider}); + + final Uint8List? bytes; + final ImageProvider? provider; + + bool get isReady => bytes != null || provider != null; +} + class PlayerFrameCapture { const PlayerFrameCapture._(); static const int budgetMilliseconds = 32; + static Future captureAndPrecache({ + required BuildContext context, + required GlobalKey boundaryKey, + required String source, + String? fallbackPosterUrl, + }) async { + final bytes = await capture( + context: context, + boundaryKey: boundaryKey, + source: source, + ); + if (bytes != null) { + if (!context.mounted) return const PreparedPlayerFrame(); + final provider = MemoryImage(bytes); + if (await _precache(context, provider, source: source, kind: 'capture')) { + _log( + '[player-entry] phase=frame-precached source=$source kind=capture', + ); + return PreparedPlayerFrame(bytes: bytes, provider: provider); + } + } + + final poster = fallbackPosterUrl?.trim() ?? ''; + if (poster.isNotEmpty) { + if (!context.mounted) return const PreparedPlayerFrame(); + final provider = NetworkImage(AppConfig.mediaUrl(poster)); + if (await _precache(context, provider, source: source, kind: 'poster')) { + _log('[player-entry] phase=frame-precached source=$source kind=poster'); + return PreparedPlayerFrame(provider: provider); + } + } + return const PreparedPlayerFrame(); + } + static Future capture({ required BuildContext context, required GlobalKey boundaryKey, @@ -57,6 +102,32 @@ class PlayerFrameCapture { } } + static Future _precache( + BuildContext context, + ImageProvider provider, { + required String source, + required String kind, + }) async { + var failed = false; + try { + await precacheImage( + provider, + context, + onError: (_, _) { + failed = true; + }, + ); + } catch (_) { + failed = true; + } + if (failed) { + _log( + '[player-entry] phase=frame-precache-failed source=$source kind=$kind', + ); + } + return !failed; + } + static void _log(String message) { if (kDebugMode) debugPrint(message); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index f772ad1..a9db4d2 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -230,7 +230,6 @@ class PlayerLogic extends ChangeNotifier { cancelScrub(); await session.pauseForRouteHandoff(); _logFeedHandoff('paused'); - await saveCurrentProgress(); } /// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。 @@ -1230,27 +1229,38 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { - final initialFrameBytes = await PlayerFrameCapture.capture( + final fallbackPosterUrl = index >= 0 && index < drama.episodes.length + ? drama.episodes[index].posterUrl + : null; + final preparedFrame = await PlayerFrameCapture.captureAndPrecache( context: context, boundaryKey: renderBoundaryKey, source: 'watch-all', + fallbackPosterUrl: fallbackPosterUrl, ); if (!context.mounted) return; await pauseForFullPlayer(); if (!context.mounted) return; - await Navigator.of(context).push( + final route = Navigator.of(context).push( FadePageRoute( - transitionDuration: const Duration(milliseconds: 180), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, builder: (_) => PlayerPage( drama: drama, initialIndex: index, wallet: wallet, downloads: downloads, - revealAfterFirstFrame: initialFrameBytes != null, - initialFrameBytes: initialFrameBytes, + revealAfterFirstFrame: preparedFrame.isReady, + initialFrameBytes: preparedFrame.bytes, + initialFrameProvider: preparedFrame.provider, ), ), ); + if (kDebugMode) { + debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0'); + } + unawaited(saveCurrentProgress()); + await route; if (!isMounted()) return; await resumeAfterFullPlayer(); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index e59148f..f37bf2c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -93,6 +93,7 @@ class PlayerPage extends StatefulWidget { this.handoff, this.revealAfterFirstFrame = false, this.initialFrameBytes, + this.initialFrameProvider, }); final Drama drama; @@ -122,6 +123,7 @@ class PlayerPage extends StatefulWidget { final PlayerPageHandoff? handoff; final bool revealAfterFirstFrame; final Uint8List? initialFrameBytes; + final ImageProvider? initialFrameProvider; @override State createState() => _PlayerPageState(); @@ -147,6 +149,7 @@ class _PlayerPageState extends State late bool _firstFrameReady; bool _leavingHandoff = false; bool _preparingFullPlayer = false; + bool _loggedInitialFrameBuild = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -157,14 +160,6 @@ class _PlayerPageState extends State void initState() { super.initState(); _firstFrameReady = !widget.revealAfterFirstFrame; - if (kDebugMode && - widget.revealAfterFirstFrame && - widget.initialFrameBytes != null) { - debugPrint( - '[player-entry] phase=initial-frame-shown ' - 'bytes=${widget.initialFrameBytes!.length}', - ); - } final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), @@ -690,6 +685,18 @@ class _PlayerPageState extends State logic.ticketError != null || logic.blockedEpisodeId != null || logic.session.error != null; + final hasInitialFrame = + widget.initialFrameBytes != null || widget.initialFrameProvider != null; + if (kDebugMode && + widget.revealAfterFirstFrame && + hasInitialFrame && + !_loggedInitialFrameBuild) { + _loggedInitialFrameBuild = true; + debugPrint( + '[player-entry] phase=first-build-with-initial-frame ' + 'kind=${widget.initialFrameBytes != null ? 'capture' : 'poster'}', + ); + } final presentedPlayer = widget.revealAfterFirstFrame ? Stack( fit: StackFit.expand, @@ -698,7 +705,15 @@ class _PlayerPageState extends State child: revealContent ? const SizedBox.expand() : widget.initialFrameBytes == null - ? _buildInitialFrameFallback() + ? widget.initialFrameProvider == null + ? _buildInitialFrameFallback() + : Image( + image: widget.initialFrameProvider!, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => + _buildInitialFrameFallback(), + ) : Image.memory( widget.initialFrameBytes!, key: const Key('player-entry-frame'), @@ -726,7 +741,7 @@ class _PlayerPageState extends State setState(() => _firstFrameReady = true); if (kDebugMode) { debugPrint( - '[player-entry] phase=ready ' + '[player-entry] phase=video-first-frame ' 'session=${identityHashCode(logic.session)}', ); } @@ -1245,10 +1260,15 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final initialFrameBytes = await PlayerFrameCapture.capture( + final fallbackPosterUrl = + episodeIndex >= 0 && episodeIndex < seriesEpisodes.length + ? seriesEpisodes[episodeIndex].posterUrl + : sourceEpisode.posterUrl; + final preparedFrame = await PlayerFrameCapture.captureAndPrecache( context: context, boundaryKey: logic.renderBoundaryKey, source: 'full-player', + fallbackPosterUrl: fallbackPosterUrl, ); if (!mounted || !context.mounted) return; final sourceUserWantsPlay = logic.session.userWantsPlay; @@ -1265,21 +1285,24 @@ class _PlayerPageState extends State PageRouteBuilder( opaque: true, barrierDismissible: false, - transitionDuration: const Duration(milliseconds: 180), - reverseTransitionDuration: const Duration(milliseconds: 180), - transitionsBuilder: (_, animation, _, child) => - FadeTransition(opacity: animation, child: child), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, pageBuilder: (_, _, _) => PlayerPage( drama: seriesDrama, initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, - revealAfterFirstFrame: initialFrameBytes != null, - initialFrameBytes: initialFrameBytes, + revealAfterFirstFrame: preparedFrame.isReady, + initialFrameBytes: preparedFrame.bytes, + initialFrameProvider: preparedFrame.provider, ), ), ); + if (kDebugMode) { + debugPrint('[player-entry] phase=route-pushed source=full-player ms=0'); + } + unawaited(logic.saveCurrentProgress()); await route; if (!mounted) return; logic.attach(widget, isMounted: () => mounted); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 3db6c3c..dff4092 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -136,7 +136,11 @@ void main() { final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); final fullPlayerWidget = tester.widget(fullPlayer); - expect(fullPlayerWidget.initialFrameBytes, isNotNull); + expect( + fullPlayerWidget.initialFrameBytes != null || + fullPlayerWidget.initialFrameProvider != null, + isTrue, + ); expect(fullPlayerWidget.revealAfterFirstFrame, isTrue); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); @@ -265,7 +269,11 @@ void main() { ); expect(player, findsOneWidget); final playerWidget = tester.widget(player); - expect(playerWidget.initialFrameBytes, isNotNull); + expect( + playerWidget.initialFrameBytes != null || + playerWidget.initialFrameProvider != null, + isTrue, + ); expect(playerWidget.revealAfterFirstFrame, isTrue); expect(playerWidget.initialIndex, 1); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 3f7e221..dde01b8 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -558,7 +558,14 @@ Future _waitForFullPlayer(WidgetTester tester) async { () => Future.delayed(const Duration(milliseconds: 10)), ); await tester.pump(const Duration(milliseconds: 16)); - if (find.byType(PlayerPage).evaluate().length > 1) return; + if (find + .byWidgetPredicate( + (widget) => widget is PlayerPage && !widget.homeMode, + ) + .evaluate() + .isNotEmpty) { + return; + } } fail('full player route was not pushed after frame capture'); } From e8f192ccf8e145c92a67b70adca96ed9f9e0470c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:31:59 +0800 Subject: [PATCH 24/42] =?UTF-8?q?fix(player):=20=E9=A1=B6=E5=B1=82?= =?UTF-8?q?=E6=8A=93=E5=B8=A7=E6=B7=A1=E5=87=BA=E6=8F=AD=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 190 +++++++++++++++--- .../player/widgets/player_render_stack.dart | 14 +- 2 files changed, 168 insertions(+), 36 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f37bf2c..ee22e73 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -147,9 +147,15 @@ class _PlayerPageState extends State bool _swipePrewarmCaptured = false; bool _presentationActive = false; late bool _firstFrameReady; + late bool _decorVisible; + late bool _decorRemoved; + bool _decorFadeScheduled = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; bool _loggedInitialFrameBuild = false; + bool _loggedDecorCovered = false; + DateTime? _entryDiagnosticsStartedAt; + final Map _entryElementStates = {}; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -160,6 +166,13 @@ class _PlayerPageState extends State void initState() { super.initState(); _firstFrameReady = !widget.revealAfterFirstFrame; + final hasInitialFrame = + widget.initialFrameBytes != null || widget.initialFrameProvider != null; + _decorVisible = widget.revealAfterFirstFrame && hasInitialFrame; + _decorRemoved = !_decorVisible; + if (widget.revealAfterFirstFrame) { + _entryDiagnosticsStartedAt = DateTime.now(); + } final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), @@ -611,6 +624,7 @@ class _PlayerPageState extends State pageController: _pageController, slots: _renderSlots(indices), onCurrentVideoVisible: _handleCurrentVideoVisible, + showNeighbors: !widget.revealAfterFirstFrame || _decorRemoved, ), ), pageLayer, @@ -678,13 +692,16 @@ class _PlayerPageState extends State ), ), ); - final revealContent = - !widget.revealAfterFirstFrame || - _firstFrameReady || - logic.session.isLocked || - logic.ticketError != null || - logic.blockedEpisodeId != null || - logic.session.error != null; + _logEntryElement('player-ui', true); + _logEntryElement( + 'home-feed-header', + widget.homeMode && + widget.showHomeFeedHeader && + !_scrubbing && + !_boosting && + !logic.pip && + !logic.pipPreparing, + ); final hasInitialFrame = widget.initialFrameBytes != null || widget.initialFrameProvider != null; if (kDebugMode && @@ -697,32 +714,40 @@ class _PlayerPageState extends State 'kind=${widget.initialFrameBytes != null ? 'capture' : 'poster'}', ); } - final presentedPlayer = widget.revealAfterFirstFrame + final usesInitialFrame = widget.revealAfterFirstFrame && hasInitialFrame; + if (usesInitialFrame && + _decorVisible && + !_loggedDecorCovered && + kDebugMode) { + _loggedDecorCovered = true; + debugPrint('[player-entry] phase=decor-covers-video'); + } + final entryUnavailableReason = logic.ticketError != null + ? 'ticket-error' + : logic.blockedEpisodeId != null + ? 'blocked-episode' + : null; + if (usesInitialFrame && _decorVisible && entryUnavailableReason != null) { + _scheduleDecorFade(entryUnavailableReason); + } + final presentedPlayer = usesInitialFrame ? Stack( fit: StackFit.expand, children: [ - Positioned.fill( - child: revealContent - ? const SizedBox.expand() - : widget.initialFrameBytes == null - ? widget.initialFrameProvider == null - ? _buildInitialFrameFallback() - : Image( - image: widget.initialFrameProvider!, - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => - _buildInitialFrameFallback(), - ) - : Image.memory( - widget.initialFrameBytes!, - key: const Key('player-entry-frame'), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _buildInitialFrameFallback(), - ), - ), - Opacity(opacity: revealContent ? 1 : 0, child: player), + player, + if (!_decorRemoved) + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + key: const Key('player-entry-decor'), + opacity: _decorVisible ? 1 : 0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + onEnd: _handleDecorFadeEnd, + child: _buildInitialFrameImage(), + ), + ), + ), ], ) : player; @@ -738,13 +763,84 @@ class _PlayerPageState extends State void _handleCurrentVideoVisible() { if (!mounted || _firstFrameReady) return; - setState(() => _firstFrameReady = true); + _firstFrameReady = true; if (kDebugMode) { debugPrint( '[player-entry] phase=video-first-frame ' 'session=${identityHashCode(logic.session)}', ); } + WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _startDecorFade('video-first-frame'); + }); + }); + } + + void _scheduleDecorFade(String reason) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _startDecorFade(reason); + }); + } + + void _startDecorFade(String reason) { + if (!mounted || !_decorVisible || _decorRemoved || _decorFadeScheduled) { + return; + } + _decorFadeScheduled = true; + if (kDebugMode) { + debugPrint('[player-entry] phase=decor-fade-start reason=$reason'); + } + setState(() => _decorVisible = false); + } + + void _handleDecorFadeEnd() { + if (!mounted || _decorVisible || _decorRemoved) return; + setState(() => _decorRemoved = true); + if (kDebugMode) { + debugPrint('[player-entry] phase=decor-removed'); + } + } + + Widget _buildInitialFrameImage() { + if (widget.initialFrameBytes != null) { + return Image.memory( + widget.initialFrameBytes!, + key: const Key('player-entry-frame'), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _buildInitialFrameFallback(), + ); + } + final provider = widget.initialFrameProvider; + if (provider != null) { + return Image( + image: provider, + key: const Key('player-entry-frame'), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _buildInitialFrameFallback(), + ); + } + return _buildInitialFrameFallback(); + } + + void _logEntryElement(String element, bool visible) { + final startedAt = _entryDiagnosticsStartedAt; + if (startedAt == null || + DateTime.now().difference(startedAt) > + const Duration(milliseconds: 800)) { + return; + } + if (_entryElementStates[element] == visible) return; + _entryElementStates[element] = visible; + if (kDebugMode) { + debugPrint( + '[player-entry] phase=element-state element=$element ' + 'visible=${visible ? 1 : 0} ' + 'ms=${DateTime.now().difference(startedAt).inMilliseconds}', + ); + } } Widget _buildInitialFrameFallback() { @@ -801,6 +897,11 @@ class _PlayerPageState extends State logic.session.previousPreloadedIndex; final next = logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex; + _logEntryElement( + 'adjacent-slots', + (!widget.revealAfterFirstFrame || _decorRemoved) && + (previous != null || next != null), + ); return { PlayerRenderSlotRole.previous: _renderSlot( PlayerRenderSlotRole.previous, @@ -847,6 +948,33 @@ class _PlayerPageState extends State final handoffPort = handoff is VideoPlayerPort ? handoff : null; final videoReady = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; + if (selected) { + final videoLoading = + !locked && + !theater && + !videoReady && + port == null && + !logic.ticketLoading && + logic.ticketError == null && + logic.session.error == null && + logic.blockedEpisodeId != episode.id && + !logic.session.isLocked; + _logEntryElement('loading-spinner', videoLoading); + _logEntryElement( + 'opening-frame', + !locked && !videoReady && episode.posterUrl?.isNotEmpty == true || + !locked && !videoReady && episode.thumbnailsUrl?.isNotEmpty == true, + ); + _logEntryElement('pause-button', !locked && !logic.session.userWantsPlay); + _logEntryElement( + 'player-controls', + !widget.homeMode && !theater && !locked, + ); + _logEntryElement( + 'buffering', + videoPort?.controller.value.isBuffering ?? false, + ); + } final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = !locked && diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index fc19e0a..4cd02f5 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -53,11 +53,13 @@ class PlayerRenderStack extends StatefulWidget { required this.pageController, required this.slots, this.onCurrentVideoVisible, + this.showNeighbors = true, }); final PageController pageController; final Map slots; final VoidCallback? onCurrentVideoVisible; + final bool showNeighbors; @override State createState() => _PlayerRenderStackState(); @@ -115,11 +117,13 @@ class _PlayerRenderStackState extends State { : MediaQuery.sizeOf(context).height; final pageOffset = _pageOffset(); final currentPage = _currentSlot.pagePosition ?? pageOffset; - final slots = [ - _slotData(PlayerRenderSlotRole.previous), - _slotData(PlayerRenderSlotRole.current), - _slotData(PlayerRenderSlotRole.next), - ]; + final slots = widget.showNeighbors + ? [ + _slotData(PlayerRenderSlotRole.previous), + _slotData(PlayerRenderSlotRole.current), + _slotData(PlayerRenderSlotRole.next), + ] + : [_slotData(PlayerRenderSlotRole.current)]; return Stack( key: const Key('player-render-stack'), fit: StackFit.expand, From ee4e573f4f3b701a2fc290023c960c2db0b18294 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:43:57 +0800 Subject: [PATCH 25/42] =?UTF-8?q?fix(player):=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=E4=B8=BA=E9=9D=9E=E9=98=BB=E5=A1=9E=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_frame_capture.dart | 134 -------- lib/pages/player/player_logic.dart | 20 +- lib/pages/player/player_view.dart | 302 +++++------------- .../widgets/player_loading_overlay.dart | 55 ++++ .../player/widgets/player_render_stack.dart | 4 +- test/player/player_fullscreen_test.dart | 159 +++++---- test/recommend/recommend_sections_test.dart | 20 -- 7 files changed, 239 insertions(+), 455 deletions(-) delete mode 100644 lib/pages/player/player_frame_capture.dart create mode 100644 lib/pages/player/widgets/player_loading_overlay.dart diff --git a/lib/pages/player/player_frame_capture.dart b/lib/pages/player/player_frame_capture.dart deleted file mode 100644 index 41e7b18..0000000 --- a/lib/pages/player/player_frame_capture.dart +++ /dev/null @@ -1,134 +0,0 @@ -// Author: fruit.com.cn - -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -import '../../core/config/app_config.dart'; - -class PreparedPlayerFrame { - const PreparedPlayerFrame({this.bytes, this.provider}); - - final Uint8List? bytes; - final ImageProvider? provider; - - bool get isReady => bytes != null || provider != null; -} - -class PlayerFrameCapture { - const PlayerFrameCapture._(); - - static const int budgetMilliseconds = 32; - - static Future captureAndPrecache({ - required BuildContext context, - required GlobalKey boundaryKey, - required String source, - String? fallbackPosterUrl, - }) async { - final bytes = await capture( - context: context, - boundaryKey: boundaryKey, - source: source, - ); - if (bytes != null) { - if (!context.mounted) return const PreparedPlayerFrame(); - final provider = MemoryImage(bytes); - if (await _precache(context, provider, source: source, kind: 'capture')) { - _log( - '[player-entry] phase=frame-precached source=$source kind=capture', - ); - return PreparedPlayerFrame(bytes: bytes, provider: provider); - } - } - - final poster = fallbackPosterUrl?.trim() ?? ''; - if (poster.isNotEmpty) { - if (!context.mounted) return const PreparedPlayerFrame(); - final provider = NetworkImage(AppConfig.mediaUrl(poster)); - if (await _precache(context, provider, source: source, kind: 'poster')) { - _log('[player-entry] phase=frame-precached source=$source kind=poster'); - return PreparedPlayerFrame(provider: provider); - } - } - return const PreparedPlayerFrame(); - } - - static Future capture({ - required BuildContext context, - required GlobalKey boundaryKey, - required String source, - }) async { - final boundary = boundaryKey.currentContext?.findRenderObject(); - if (boundary is! RenderRepaintBoundary) { - _log('[player-entry] phase=frame-capture-unavailable source=$source'); - return null; - } - final stopwatch = Stopwatch()..start(); - try { - final pixelRatio = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 2.0).toDouble(); - final image = await boundary.toImage(pixelRatio: pixelRatio); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - if (stopwatch.elapsedMilliseconds > budgetMilliseconds) { - _log( - '[player-entry] phase=frame-capture-over-budget source=$source ' - 'ms=${stopwatch.elapsedMilliseconds} budget_ms=$budgetMilliseconds', - ); - } - if (data == null) { - _log('[player-entry] phase=frame-capture-empty source=$source'); - return null; - } - final bytes = data.buffer.asUint8List( - data.offsetInBytes, - data.lengthInBytes, - ); - _log( - '[player-entry] phase=frame-captured source=$source ' - 'bytes=${bytes.length} ms=${stopwatch.elapsedMilliseconds}', - ); - return bytes; - } catch (error) { - _log( - '[player-entry] phase=frame-capture-failed source=$source ' - 'error=$error', - ); - return null; - } - } - - static Future _precache( - BuildContext context, - ImageProvider provider, { - required String source, - required String kind, - }) async { - var failed = false; - try { - await precacheImage( - provider, - context, - onError: (_, _) { - failed = true; - }, - ); - } catch (_) { - failed = true; - } - if (failed) { - _log( - '[player-entry] phase=frame-precache-failed source=$source kind=$kind', - ); - } - return !failed; - } - - static void _log(String message) { - if (kDebugMode) debugPrint(message); - } -} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index a9db4d2..ac76aa9 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -32,7 +32,6 @@ import '../discover/search_page.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; -import 'player_frame_capture.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; import 'widgets/episode_picker_sheet.dart'; @@ -65,9 +64,6 @@ class PlayerLogic extends ChangeNotifier { final Future Function() _waitForFrame; late PageController pages; - final GlobalKey renderBoundaryKey = GlobalKey( - debugLabel: 'player-render-boundary', - ); late PlaybackQueue queue; late List visibleIndices; late PlaybackSession session; @@ -1229,18 +1225,6 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { - final fallbackPosterUrl = index >= 0 && index < drama.episodes.length - ? drama.episodes[index].posterUrl - : null; - final preparedFrame = await PlayerFrameCapture.captureAndPrecache( - context: context, - boundaryKey: renderBoundaryKey, - source: 'watch-all', - fallbackPosterUrl: fallbackPosterUrl, - ); - if (!context.mounted) return; - await pauseForFullPlayer(); - if (!context.mounted) return; final route = Navigator.of(context).push( FadePageRoute( transitionDuration: Duration.zero, @@ -1250,15 +1234,13 @@ class PlayerLogic extends ChangeNotifier { initialIndex: index, wallet: wallet, downloads: downloads, - revealAfterFirstFrame: preparedFrame.isReady, - initialFrameBytes: preparedFrame.bytes, - initialFrameProvider: preparedFrame.provider, ), ), ); if (kDebugMode) { debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0'); } + unawaited(pauseForFullPlayer()); unawaited(saveCurrentProgress()); await route; if (!isMounted()) return; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index ee22e73..f89976c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -32,13 +32,13 @@ import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.dart'; import 'comments_auth.dart'; import 'player_binding.dart'; -import 'player_frame_capture.dart'; import 'player_logic.dart'; import 'widgets/home_feed_header.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/feed_pull_refresh.dart'; import 'widgets/player_center_toggle.dart'; +import 'widgets/player_loading_overlay.dart'; import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; @@ -91,9 +91,6 @@ class PlayerPage extends StatefulWidget { this.wallet, this.downloads, this.handoff, - this.revealAfterFirstFrame = false, - this.initialFrameBytes, - this.initialFrameProvider, }); final Drama drama; @@ -121,9 +118,6 @@ class PlayerPage extends StatefulWidget { final WalletController? wallet; final DownloadController? downloads; final PlayerPageHandoff? handoff; - final bool revealAfterFirstFrame; - final Uint8List? initialFrameBytes; - final ImageProvider? initialFrameProvider; @override State createState() => _PlayerPageState(); @@ -146,16 +140,13 @@ class _PlayerPageState extends State DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; bool _presentationActive = false; - late bool _firstFrameReady; - late bool _decorVisible; - late bool _decorRemoved; - bool _decorFadeScheduled = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; - bool _loggedInitialFrameBuild = false; - bool _loggedDecorCovered = false; - DateTime? _entryDiagnosticsStartedAt; - final Map _entryElementStates = {}; + int? _loadingIndex; + int? _videoReadyIndex; + bool _loadingVisible = false; + bool _loadingRemoved = true; + bool _loadingFadeScheduled = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -165,14 +156,6 @@ class _PlayerPageState extends State @override void initState() { super.initState(); - _firstFrameReady = !widget.revealAfterFirstFrame; - final hasInitialFrame = - widget.initialFrameBytes != null || widget.initialFrameProvider != null; - _decorVisible = widget.revealAfterFirstFrame && hasInitialFrame; - _decorRemoved = !_decorVisible; - if (widget.revealAfterFirstFrame) { - _entryDiagnosticsStartedAt = DateTime.now(); - } final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), @@ -196,11 +179,10 @@ class _PlayerPageState extends State initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, ); _pageController = logic.pages; - if (kDebugMode && widget.revealAfterFirstFrame) { - debugPrint( - '[player-entry] phase=waiting-first-frame ' - 'session=${identityHashCode(logic.session)}', - ); + if (!widget.embedded) { + _loadingIndex = logic.session.currentIndex; + _loadingVisible = true; + _loadingRemoved = false; } } @@ -562,6 +544,33 @@ class _PlayerPageState extends State final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; + final currentIndex = logic.session.currentIndex; + if (!widget.embedded && _loadingIndex != currentIndex) { + _loadingIndex = currentIndex; + _videoReadyIndex = null; + _loadingVisible = true; + _loadingRemoved = false; + _loadingFadeScheduled = false; + } + final entryUnavailable = + logic.ticketError != null || + logic.blockedEpisodeId != null || + logic.session.error != null || + logic.isEpisodeLocked(logic.session.currentEpisode); + if (!widget.embedded && + !_loadingRemoved && + !_loadingFadeScheduled && + entryUnavailable) { + _scheduleLoadingFade('entry-unavailable'); + } + final activePort = logic.session.active; + if (!widget.embedded && + !_loadingRemoved && + !_loadingFadeScheduled && + activePort != null && + activePort is! VideoPlayerPort) { + _scheduleLoadingFade('non-video-player-ready'); + } final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { @@ -618,14 +627,11 @@ class _PlayerPageState extends State return Stack( fit: StackFit.expand, children: [ - RepaintBoundary( - key: logic.renderBoundaryKey, - child: PlayerRenderStack( - pageController: _pageController, - slots: _renderSlots(indices), - onCurrentVideoVisible: _handleCurrentVideoVisible, - showNeighbors: !widget.revealAfterFirstFrame || _decorRemoved, - ), + PlayerRenderStack( + pageController: _pageController, + slots: _renderSlots(indices), + onCurrentVideoVisible: _handleCurrentVideoVisible, + showNeighbors: widget.embedded || _loadingRemoved, ), pageLayer, ], @@ -669,6 +675,14 @@ class _PlayerPageState extends State ), ), ), + if (!widget.embedded && !_loadingRemoved) + Positioned.fill( + child: PlayerLoadingOverlay( + key: ValueKey('player-loading-${_loadingIndex ?? currentIndex}'), + opacity: _loadingVisible ? 1 : 0, + onFadeEnd: _handleLoadingFadeEnd, + ), + ), ], ); final player = AnnotatedRegion( @@ -692,78 +706,25 @@ class _PlayerPageState extends State ), ), ); - _logEntryElement('player-ui', true); - _logEntryElement( - 'home-feed-header', - widget.homeMode && - widget.showHomeFeedHeader && - !_scrubbing && - !_boosting && - !logic.pip && - !logic.pipPreparing, - ); - final hasInitialFrame = - widget.initialFrameBytes != null || widget.initialFrameProvider != null; - if (kDebugMode && - widget.revealAfterFirstFrame && - hasInitialFrame && - !_loggedInitialFrameBuild) { - _loggedInitialFrameBuild = true; - debugPrint( - '[player-entry] phase=first-build-with-initial-frame ' - 'kind=${widget.initialFrameBytes != null ? 'capture' : 'poster'}', - ); - } - final usesInitialFrame = widget.revealAfterFirstFrame && hasInitialFrame; - if (usesInitialFrame && - _decorVisible && - !_loggedDecorCovered && - kDebugMode) { - _loggedDecorCovered = true; - debugPrint('[player-entry] phase=decor-covers-video'); - } - final entryUnavailableReason = logic.ticketError != null - ? 'ticket-error' - : logic.blockedEpisodeId != null - ? 'blocked-episode' - : null; - if (usesInitialFrame && _decorVisible && entryUnavailableReason != null) { - _scheduleDecorFade(entryUnavailableReason); - } - final presentedPlayer = usesInitialFrame - ? Stack( - fit: StackFit.expand, - children: [ - player, - if (!_decorRemoved) - Positioned.fill( - child: IgnorePointer( - child: AnimatedOpacity( - key: const Key('player-entry-decor'), - opacity: _decorVisible ? 1 : 0, - duration: const Duration(milliseconds: 140), - curve: Curves.easeOut, - onEnd: _handleDecorFadeEnd, - child: _buildInitialFrameImage(), - ), - ), - ), - ], - ) - : player; - if (widget.handoff == null) return presentedPlayer; + if (widget.handoff == null) return player; return PopScope( canPop: _leavingHandoff, onPopInvokedWithResult: (didPop, _) { if (!didPop) unawaited(_leaveHandoff(context)); }, - child: presentedPlayer, + child: player, ); } - void _handleCurrentVideoVisible() { - if (!mounted || _firstFrameReady) return; - _firstFrameReady = true; + void _handleCurrentVideoVisible(int? index) { + if (!mounted) return; + final currentIndex = logic.session.currentIndex; + if (index == null || + index != currentIndex || + _videoReadyIndex == currentIndex) { + return; + } + _videoReadyIndex = currentIndex; if (kDebugMode) { debugPrint( '[player-entry] phase=video-first-frame ' @@ -772,95 +733,41 @@ class _PlayerPageState extends State } WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) { - _startDecorFade('video-first-frame'); + _startLoadingFade(currentIndex); }); }); } - void _scheduleDecorFade(String reason) { + void _scheduleLoadingFade(String reason) { WidgetsBinding.instance.addPostFrameCallback((_) { - _startDecorFade(reason); + _startLoadingFade(logic.session.currentIndex, reason: reason); }); } - void _startDecorFade(String reason) { - if (!mounted || !_decorVisible || _decorRemoved || _decorFadeScheduled) { + void _startLoadingFade(int index, {String reason = 'video-first-frame'}) { + if (!mounted || + widget.embedded || + index != logic.session.currentIndex || + !_loadingVisible || + _loadingRemoved || + _loadingFadeScheduled) { return; } - _decorFadeScheduled = true; + _loadingFadeScheduled = true; if (kDebugMode) { - debugPrint('[player-entry] phase=decor-fade-start reason=$reason'); + debugPrint('[player-entry] phase=loading-fade-start reason=$reason'); } - setState(() => _decorVisible = false); + setState(() => _loadingVisible = false); } - void _handleDecorFadeEnd() { - if (!mounted || _decorVisible || _decorRemoved) return; - setState(() => _decorRemoved = true); + void _handleLoadingFadeEnd() { + if (!mounted || _loadingVisible || _loadingRemoved) return; + setState(() => _loadingRemoved = true); if (kDebugMode) { - debugPrint('[player-entry] phase=decor-removed'); + debugPrint('[player-entry] phase=loading-removed'); } } - Widget _buildInitialFrameImage() { - if (widget.initialFrameBytes != null) { - return Image.memory( - widget.initialFrameBytes!, - key: const Key('player-entry-frame'), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _buildInitialFrameFallback(), - ); - } - final provider = widget.initialFrameProvider; - if (provider != null) { - return Image( - image: provider, - key: const Key('player-entry-frame'), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _buildInitialFrameFallback(), - ); - } - return _buildInitialFrameFallback(); - } - - void _logEntryElement(String element, bool visible) { - final startedAt = _entryDiagnosticsStartedAt; - if (startedAt == null || - DateTime.now().difference(startedAt) > - const Duration(milliseconds: 800)) { - return; - } - if (_entryElementStates[element] == visible) return; - _entryElementStates[element] = visible; - if (kDebugMode) { - debugPrint( - '[player-entry] phase=element-state element=$element ' - 'visible=${visible ? 1 : 0} ' - 'ms=${DateTime.now().difference(startedAt).inMilliseconds}', - ); - } - } - - Widget _buildInitialFrameFallback() { - final index = widget.initialIndex; - if (index < 0 || index >= widget.drama.episodes.length) { - return const ColoredBox( - key: Key('player-entry-black'), - color: AppColors.mediaBlack, - ); - } - final episode = widget.drama.episodes[index]; - return playerOpeningFrame( - episode.thumbnailsUrl, - posterUrl: episode.posterUrl, - spriteSource: _spriteAxisService, - fit: BoxFit.cover, - ) ?? - const ColoredBox(color: AppColors.mediaBlack); - } - void _openCurrentMore() { final entry = logic.queue.entryAt(logic.session.currentIndex); _openMore(context, entry.episode); @@ -897,11 +804,6 @@ class _PlayerPageState extends State logic.session.previousPreloadedIndex; final next = logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex; - _logEntryElement( - 'adjacent-slots', - (!widget.revealAfterFirstFrame || _decorRemoved) && - (previous != null || next != null), - ); return { PlayerRenderSlotRole.previous: _renderSlot( PlayerRenderSlotRole.previous, @@ -948,33 +850,6 @@ class _PlayerPageState extends State final handoffPort = handoff is VideoPlayerPort ? handoff : null; final videoReady = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; - if (selected) { - final videoLoading = - !locked && - !theater && - !videoReady && - port == null && - !logic.ticketLoading && - logic.ticketError == null && - logic.session.error == null && - logic.blockedEpisodeId != episode.id && - !logic.session.isLocked; - _logEntryElement('loading-spinner', videoLoading); - _logEntryElement( - 'opening-frame', - !locked && !videoReady && episode.posterUrl?.isNotEmpty == true || - !locked && !videoReady && episode.thumbnailsUrl?.isNotEmpty == true, - ); - _logEntryElement('pause-button', !locked && !logic.session.userWantsPlay); - _logEntryElement( - 'player-controls', - !widget.homeMode && !theater && !locked, - ); - _logEntryElement( - 'buffering', - videoPort?.controller.value.isBuffering ?? false, - ); - } final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = !locked && @@ -1388,24 +1263,7 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final fallbackPosterUrl = - episodeIndex >= 0 && episodeIndex < seriesEpisodes.length - ? seriesEpisodes[episodeIndex].posterUrl - : sourceEpisode.posterUrl; - final preparedFrame = await PlayerFrameCapture.captureAndPrecache( - context: context, - boundaryKey: logic.renderBoundaryKey, - source: 'full-player', - fallbackPosterUrl: fallbackPosterUrl, - ); - if (!mounted || !context.mounted) return; final sourceUserWantsPlay = logic.session.userWantsPlay; - try { - await logic.pauseForFullPlayer(); - } finally { - _preparingFullPlayer = false; - } - if (!mounted || !context.mounted) return; final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay); _logFullPlayerRoute('present'); setState(() => _presentationActive = true); @@ -1421,15 +1279,13 @@ class _PlayerPageState extends State handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, - revealAfterFirstFrame: preparedFrame.isReady, - initialFrameBytes: preparedFrame.bytes, - initialFrameProvider: preparedFrame.provider, ), ), ); if (kDebugMode) { debugPrint('[player-entry] phase=route-pushed source=full-player ms=0'); } + unawaited(logic.pauseForFullPlayer()); unawaited(logic.saveCurrentProgress()); await route; if (!mounted) return; diff --git a/lib/pages/player/widgets/player_loading_overlay.dart b/lib/pages/player/widgets/player_loading_overlay.dart new file mode 100644 index 0000000..eb890a3 --- /dev/null +++ b/lib/pages/player/widgets/player_loading_overlay.dart @@ -0,0 +1,55 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; + +class PlayerLoadingOverlay extends StatelessWidget { + const PlayerLoadingOverlay({ + super.key, + required this.opacity, + required this.onFadeEnd, + }); + + final double opacity; + final VoidCallback onFadeEnd; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + key: const Key('player-entry-loading-overlay'), + child: AnimatedOpacity( + opacity: opacity, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + onEnd: onFadeEnd, + child: ColoredBox( + color: AppColors.mediaBlack, + child: Center( + child: Container( + key: const Key('player-loading-surface'), + width: AppDimens.refreshIndicatorSize, + height: AppDimens.refreshIndicatorSize, + decoration: BoxDecoration( + color: AppColors.refreshIndicatorSurface, + borderRadius: BorderRadius.circular( + AppDimens.refreshIndicatorRadius, + ), + ), + alignment: Alignment.center, + child: const SizedBox.square( + dimension: AppDimens.refreshSpinnerSize, + child: CircularProgressIndicator( + key: Key('player-entry-loading'), + color: Colors.white, + strokeWidth: AppDimens.refreshSpinnerStroke, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 4cd02f5..28489dd 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -58,7 +58,7 @@ class PlayerRenderStack extends StatefulWidget { final PageController pageController; final Map slots; - final VoidCallback? onCurrentVideoVisible; + final ValueChanged? onCurrentVideoVisible; final bool showNeighbors; @override @@ -202,7 +202,7 @@ class _PlayerRenderStackState extends State { warmSlotReused: slot.warmSlotReused, waitForPlaybackFrame: slot.waitForPlaybackFrame, onVideoVisible: slot.role == PlayerRenderSlotRole.current - ? widget.onCurrentVideoVisible + ? () => widget.onCurrentVideoVisible?.call(slot.index) : null, ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index dff4092..dd45c1d 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -129,30 +129,32 @@ void main() { expect(sourceLiveBefore, greaterThan(1)); await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); final fullPlayer = _fullPlayerFinder(); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); - final fullPlayerWidget = tester.widget(fullPlayer); - expect( - fullPlayerWidget.initialFrameBytes != null || - fullPlayerWidget.initialFrameProvider != null, - isTrue, - ); - expect(fullPlayerWidget.revealAfterFirstFrame, isTrue); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); expect(feedPlayer.disposed, isFalse); expect(feedPlayer.playing, isFalse); + final fullPlayerPage = find + .descendant( + of: _fullPlayerFinder(), + matching: find.byWidgetPredicate( + (widget) => + widget is PageView && widget.scrollDirection == Axis.vertical, + ), + ) + .first; for (var index = 0; index < 4; index++) { await tester.drag( - find.byKey(const Key('player-video-tap')), + fullPlayerPage, const Offset(0, -560), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); } expect(fullLogic.session.currentEpisode.number, 5); @@ -168,7 +170,8 @@ void main() { expect(feedPlayer.disposed, isFalse); final requestsBeforeReturn = playback.requests.length; - await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-back')).hitTestable()); await tester.pumpAndSettle(); await sourceLogic.session.preloadSettled; await tester.pumpAndSettle(); @@ -229,7 +232,6 @@ void main() { expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); expect(_fullPlayerFinder(), findsOneWidget); @@ -250,36 +252,109 @@ void main() { expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); - testWidgets('should_capture_feed_frame_when_opening_series_from_picker', ( + testWidgets('should_show_non_blocking_loading_when_opening_series', ( tester, ) async { - final factory = _RouteVideoFactory(); - await _pumpApp(tester, playerFactory: factory.create); + final initialization = Completer(); + final routeFactory = _RouteVideoFactory(); + await _pumpApp( + tester, + playerFactory: (episode) => episode.id == 'the-signal-demo-2' + ? _GatedPlayer(initialization.future) + : routeFactory.create(episode), + ); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-episodes'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-episode-2'))); - await _waitForRevealedPlayerEntry(tester); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); - final player = find.byWidgetPredicate( - (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, + final loadingOverlay = find.byKey( + const Key('player-entry-loading-overlay'), ); - expect(player, findsOneWidget); - final playerWidget = tester.widget(player); + final fullPlayer = find.byType(PlayerPage).last; + final fullLogic = _pageLogic(tester, fullPlayer); + final fullPlayerPage = find + .descendant( + of: fullPlayer, + matching: find.byWidgetPredicate( + (widget) => + widget is PageView && widget.scrollDirection == Axis.vertical, + ), + ) + .first; + expect(loadingOverlay, findsOneWidget); + expect(tester.widget(loadingOverlay).ignoring, isTrue); expect( - playerWidget.initialFrameBytes != null || - playerWidget.initialFrameProvider != null, - isTrue, + tester + .widget( + find.descendant( + of: loadingOverlay, + matching: find.byType(AnimatedOpacity), + ), + ) + .duration, + const Duration(milliseconds: 140), ); - expect(playerWidget.revealAfterFirstFrame, isTrue); - expect(playerWidget.initialIndex, 1); + expect( + tester.getSize(find.byKey(const Key('player-loading-surface'))), + const Size(40, 40), + ); + expect( + tester.getSize(find.byKey(const Key('player-entry-loading'))), + const Size(20, 20), + ); + expect( + tester + .widget( + find.byKey(const Key('player-entry-loading')), + ) + .color, + Colors.white, + ); + expect( + tester + .widget( + find.descendant( + of: loadingOverlay, + matching: find.byType(ColoredBox), + ), + ) + .color, + AppColors.mediaBlack, + ); + final loadingSurface = tester.widget( + find.byKey(const Key('player-loading-surface')), + ); + final decoration = loadingSurface.decoration! as BoxDecoration; + expect(decoration.color, AppColors.refreshIndicatorSurface); + expect(decoration.borderRadius, BorderRadius.circular(20)); - await tester.tap(find.byKey(const Key('player-back'))); + // IgnorePointer 让加载中的播放暂停与上下滑仍能透传。 + expect(fullLogic.session.userWantsPlay, isTrue); + await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable()); + await tester.pump(); + expect(fullLogic.session.userWantsPlay, isFalse); + final indexBeforeSwipe = fullLogic.session.currentIndex; + await tester.drag( + fullPlayerPage, + const Offset(0, -560), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(fullLogic.session.currentIndex, isNot(indexBeforeSwipe)); + + // IgnorePointer 让加载中的返回按钮仍可点击。 + await tester.pumpAndSettle(); + expect(loadingOverlay, findsNothing); + await tester.tap(find.byKey(const Key('player-back')).hitTestable()); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); + initialization.complete(); + await tester.pump(); }); testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { @@ -304,7 +379,6 @@ void main() { expect(feedIndex, 1); await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); final fullLogic = _pageLogic(tester, _fullPlayerFinder()); @@ -1700,35 +1774,6 @@ Finder _fullPlayerFinder() { ); } -Future _waitForFullPlayerEntry(WidgetTester tester) async { - for (var attempt = 0; attempt < 120; attempt++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(const Duration(milliseconds: 16)); - if (_fullPlayerFinder().evaluate().isNotEmpty) return; - } - fail('full player route was not pushed after frame capture'); -} - -Future _waitForRevealedPlayerEntry(WidgetTester tester) async { - for (var attempt = 0; attempt < 120; attempt++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(const Duration(milliseconds: 16)); - if (find - .byWidgetPredicate( - (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, - ) - .evaluate() - .isNotEmpty) { - return; - } - } - fail('revealed player route was not pushed after frame capture'); -} - class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index dde01b8..c39412e 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -470,7 +470,6 @@ void main() { await _pumpRecommend(tester); await tester.tap(find.byKey(const Key('home-feed-series-name'))); - await _waitForFullPlayer(tester); await tester.pumpAndSettle(); expect(find.byType(PlayerPage).last, findsOneWidget); expect( @@ -485,7 +484,6 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await _waitForFullPlayer(tester); await tester.pumpAndSettle(); expect(find.byType(PlayerPage).last, findsOneWidget); expect( @@ -552,24 +550,6 @@ void main() { }); } -Future _waitForFullPlayer(WidgetTester tester) async { - for (var attempt = 0; attempt < 120; attempt++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(const Duration(milliseconds: 16)); - if (find - .byWidgetPredicate( - (widget) => widget is PlayerPage && !widget.homeMode, - ) - .evaluate() - .isNotEmpty) { - return; - } - } - fail('full player route was not pushed after frame capture'); -} - Future _pumpRecommend(WidgetTester tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; From b291044acc2599efeb5d268fc41a463f787b0d2c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:50:28 +0800 Subject: [PATCH 26/42] =?UTF-8?q?fix(player):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=A1=B5=E5=85=A5=E5=8F=A3=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 79 ++++++++++++------ .../widgets/player_loading_overlay.dart | 50 +++++------- lib/pages/player/widgets/player_overlay.dart | 1 + .../player/widgets/player_render_stack.dart | 14 ++-- .../player/widgets/seek_progress_bar.dart | 28 ++++++- test/player/player_fullscreen_test.dart | 81 +++++++++++++------ test/support/fake_catalog.dart | 1 + 7 files changed, 164 insertions(+), 90 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f89976c..355bb62 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -142,11 +142,11 @@ class _PlayerPageState extends State bool _presentationActive = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; - int? _loadingIndex; int? _videoReadyIndex; bool _loadingVisible = false; bool _loadingRemoved = true; bool _loadingFadeScheduled = false; + bool _loggedLoadingUiVisible = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -179,10 +179,16 @@ class _PlayerPageState extends State initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, ); _pageController = logic.pages; - if (!widget.embedded) { - _loadingIndex = logic.session.currentIndex; + if (!widget.embedded && + !logic.isEpisodeLocked(logic.session.currentEpisode)) { _loadingVisible = true; _loadingRemoved = false; + if (kDebugMode) { + debugPrint( + '[player-entry] phase=loading-shown-once ' + 'index=${logic.session.currentIndex}', + ); + } } } @@ -352,6 +358,15 @@ class _PlayerPageState extends State notification.dragDetails != null) { _swipeStartedAt = DateTime.now(); _swipePrewarmCaptured = false; + if (!widget.embedded && !_loadingRemoved) { + setState(() { + _loadingVisible = false; + _loadingRemoved = true; + }); + if (kDebugMode) { + debugPrint('[player-entry] phase=loading-dismissed-on-swipe'); + } + } } else if (notification is ScrollUpdateNotification && !_swipePrewarmCaptured && notification.dragDetails != null) { @@ -544,14 +559,12 @@ class _PlayerPageState extends State final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; - final currentIndex = logic.session.currentIndex; - if (!widget.embedded && _loadingIndex != currentIndex) { - _loadingIndex = currentIndex; - _videoReadyIndex = null; - _loadingVisible = true; - _loadingRemoved = false; - _loadingFadeScheduled = false; - } + final activePort = logic.session.active; + final activeVideo = activePort is VideoPlayerPort ? activePort : null; + final canWaitForVideoFrame = + activeVideo != null && + activeVideo.controller.value.isInitialized && + !activeVideo.controller.value.hasError; final entryUnavailable = logic.ticketError != null || logic.blockedEpisodeId != null || @@ -560,10 +573,10 @@ class _PlayerPageState extends State if (!widget.embedded && !_loadingRemoved && !_loadingFadeScheduled && - entryUnavailable) { + entryUnavailable && + !canWaitForVideoFrame) { _scheduleLoadingFade('entry-unavailable'); } - final activePort = logic.session.active; if (!widget.embedded && !_loadingRemoved && !_loadingFadeScheduled && @@ -631,8 +644,17 @@ class _PlayerPageState extends State pageController: _pageController, slots: _renderSlots(indices), onCurrentVideoVisible: _handleCurrentVideoVisible, - showNeighbors: widget.embedded || _loadingRemoved, ), + if (!widget.embedded && !_loadingRemoved) + Center( + child: IgnorePointer( + key: const Key('player-entry-loading-overlay'), + child: PlayerLoadingOverlay( + opacity: _loadingVisible ? 1 : 0, + onFadeEnd: _handleLoadingFadeEnd, + ), + ), + ), pageLayer, ], ); @@ -675,14 +697,6 @@ class _PlayerPageState extends State ), ), ), - if (!widget.embedded && !_loadingRemoved) - Positioned.fill( - child: PlayerLoadingOverlay( - key: ValueKey('player-loading-${_loadingIndex ?? currentIndex}'), - opacity: _loadingVisible ? 1 : 0, - onFadeEnd: _handleLoadingFadeEnd, - ), - ), ], ); final player = AnnotatedRegion( @@ -717,7 +731,7 @@ class _PlayerPageState extends State } void _handleCurrentVideoVisible(int? index) { - if (!mounted) return; + if (!mounted || _loadingRemoved) return; final currentIndex = logic.session.currentIndex; if (index == null || index != currentIndex || @@ -733,6 +747,14 @@ class _PlayerPageState extends State } WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + _loadingRemoved || + index != logic.session.currentIndex) { + return; + } + if (kDebugMode) { + debugPrint('[player-entry] phase=video-frame-painted index=$index'); + } _startLoadingFade(currentIndex); }); }); @@ -938,11 +960,20 @@ class _PlayerPageState extends State if (selected && videoReady) _logSwipeReveal(); final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; + final entryLoading = !widget.embedded && !_loadingRemoved; + if (kDebugMode && entryLoading && selected && !_loggedLoadingUiVisible) { + _loggedLoadingUiVisible = true; + debugPrint( + '[player-entry] phase=loading-ui-visible ' + 'index=$index top=1 rail=1 progress=1', + ); + } return Stack( fit: StackFit.expand, children: [ if (selected && + !entryLoading && !locked && !theater && !ready && @@ -962,7 +993,7 @@ class _PlayerPageState extends State ), ), // 票据三态之一:起播前正在向后端要签名地址 - if (selected && !theater && logic.ticketLoading) + if (selected && !entryLoading && !theater && logic.ticketLoading) const Center( child: SizedBox.square( dimension: _playerLoadingSize, diff --git a/lib/pages/player/widgets/player_loading_overlay.dart b/lib/pages/player/widgets/player_loading_overlay.dart index eb890a3..a7ce449 100644 --- a/lib/pages/player/widgets/player_loading_overlay.dart +++ b/lib/pages/player/widgets/player_loading_overlay.dart @@ -17,36 +17,26 @@ class PlayerLoadingOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return IgnorePointer( - key: const Key('player-entry-loading-overlay'), - child: AnimatedOpacity( - opacity: opacity, - duration: const Duration(milliseconds: 140), - curve: Curves.easeOut, - onEnd: onFadeEnd, - child: ColoredBox( - color: AppColors.mediaBlack, - child: Center( - child: Container( - key: const Key('player-loading-surface'), - width: AppDimens.refreshIndicatorSize, - height: AppDimens.refreshIndicatorSize, - decoration: BoxDecoration( - color: AppColors.refreshIndicatorSurface, - borderRadius: BorderRadius.circular( - AppDimens.refreshIndicatorRadius, - ), - ), - alignment: Alignment.center, - child: const SizedBox.square( - dimension: AppDimens.refreshSpinnerSize, - child: CircularProgressIndicator( - key: Key('player-entry-loading'), - color: Colors.white, - strokeWidth: AppDimens.refreshSpinnerStroke, - ), - ), - ), + return AnimatedOpacity( + opacity: opacity, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + onEnd: onFadeEnd, + child: Container( + key: const Key('player-loading-surface'), + width: AppDimens.refreshIndicatorSize, + height: AppDimens.refreshIndicatorSize, + decoration: BoxDecoration( + color: AppColors.refreshIndicatorSurface, + borderRadius: BorderRadius.circular(AppDimens.refreshIndicatorRadius), + ), + alignment: Alignment.center, + child: const SizedBox.square( + dimension: AppDimens.refreshSpinnerSize, + child: CircularProgressIndicator( + key: Key('player-entry-loading'), + color: Colors.white, + strokeWidth: AppDimens.refreshSpinnerStroke, ), ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 0a89365..d5f90b8 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -304,6 +304,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, inactiveTrackColor: AppColors.inactiveTrack, + fallbackDuration: Duration(seconds: data.episode.durationSec), onScrubStart: actions.onScrubStart, onScrubEnd: actions.onScrubEnd, onSeek: actions.onSeek, diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 28489dd..4cf3c56 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -53,13 +53,11 @@ class PlayerRenderStack extends StatefulWidget { required this.pageController, required this.slots, this.onCurrentVideoVisible, - this.showNeighbors = true, }); final PageController pageController; final Map slots; final ValueChanged? onCurrentVideoVisible; - final bool showNeighbors; @override State createState() => _PlayerRenderStackState(); @@ -117,13 +115,11 @@ class _PlayerRenderStackState extends State { : MediaQuery.sizeOf(context).height; final pageOffset = _pageOffset(); final currentPage = _currentSlot.pagePosition ?? pageOffset; - final slots = widget.showNeighbors - ? [ - _slotData(PlayerRenderSlotRole.previous), - _slotData(PlayerRenderSlotRole.current), - _slotData(PlayerRenderSlotRole.next), - ] - : [_slotData(PlayerRenderSlotRole.current)]; + final slots = [ + _slotData(PlayerRenderSlotRole.previous), + _slotData(PlayerRenderSlotRole.current), + _slotData(PlayerRenderSlotRole.next), + ]; return Stack( key: const Key('player-render-stack'), fit: StackFit.expand, diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index f0bbf32..7e566f9 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -42,6 +42,7 @@ class SeekProgressBar extends StatefulWidget { this.spriteAxisService, this.episodeId, this.thumbnailsUrl, + this.fallbackDuration = Duration.zero, }); final ValueListenable? valueListenable; @@ -57,6 +58,7 @@ class SeekProgressBar extends StatefulWidget { final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; final String? thumbnailsUrl; + final Duration fallbackDuration; @override State createState() => _SeekProgressBarState(); @@ -220,7 +222,10 @@ class _SeekProgressBarState extends State { } Widget _buildTrack(BuildContext context, VideoPlayerValue video) { - final total = video.duration.inMilliseconds; + final duration = video.duration > Duration.zero + ? video.duration + : widget.fallbackDuration; + final total = duration.inMilliseconds; final scrubbing = _dragging; final loading = !widget.locked && !scrubbing && _buffering; final enabled = @@ -234,7 +239,7 @@ class _SeekProgressBarState extends State { final fraction = _previewFraction.value ?? played; final previewPosition = _previewFraction.value == null ? video.position - : seekPositionForFraction(fraction, video.duration); + : seekPositionForFraction(fraction, duration); final showPreview = scrubbing && widget.showTimes && @@ -248,11 +253,26 @@ class _SeekProgressBarState extends State { final track = Column( mainAxisSize: MainAxisSize.min, children: [ + if (widget.showTimes && !video.isInitialized) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '00:00 / ${_format(duration)}', + key: const Key('player-progress-uninitialized-time'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ), if (widget.showTimes && scrubbing && !showPreview) Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( - '${_format(previewPosition)} / ${_format(video.duration)}', + '${_format(previewPosition)} / ${_format(duration)}', textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onPrimary, @@ -350,7 +370,7 @@ class _SeekProgressBarState extends State { thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, position: previewPosition, - duration: video.duration, + duration: duration, ), ), ], diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index dd45c1d..915cadd 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -139,6 +139,15 @@ void main() { expect(feedPlayer.disposed, isFalse); expect(feedPlayer.playing, isFalse); + final fullCurrentSurface = find.descendant( + of: fullPlayer, + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + final fullCurrentSurfaceState = tester.state(fullCurrentSurface); + final loadingOverlay = find.byKey( + const Key('player-entry-loading-overlay'), + ); + expect(loadingOverlay, findsNothing); final fullPlayerPage = find .descendant( of: _fullPlayerFinder(), @@ -149,15 +158,14 @@ void main() { ) .first; for (var index = 0; index < 4; index++) { - await tester.drag( - fullPlayerPage, - const Offset(0, -560), - ); + await tester.drag(fullPlayerPage, const Offset(0, -560)); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); + expect(loadingOverlay, findsNothing); } expect(fullLogic.session.currentEpisode.number, 5); + expect(tester.state(fullCurrentSurface), same(fullCurrentSurfaceState)); expect( fullLogic.queue.entryAt(fullLogic.session.currentIndex).drama.id, sourceDramaId, @@ -260,7 +268,7 @@ void main() { await _pumpApp( tester, playerFactory: (episode) => episode.id == 'the-signal-demo-2' - ? _GatedPlayer(initialization.future) + ? _GatedVideoPlayer(episode, initialization.future) : routeFactory.create(episode), ); await tester.tap(find.byKey(const Key('tab-recommend'))); @@ -270,13 +278,18 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-episode-2'))); await tester.pump(); - await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 300)); final loadingOverlay = find.byKey( const Key('player-entry-loading-overlay'), ); final fullPlayer = find.byType(PlayerPage).last; final fullLogic = _pageLogic(tester, fullPlayer); + final fullCurrentSurface = find.descendant( + of: fullPlayer, + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + final fullCurrentSurfaceState = tester.state(fullCurrentSurface); final fullPlayerPage = find .descendant( of: fullPlayer, @@ -288,6 +301,7 @@ void main() { .first; expect(loadingOverlay, findsOneWidget); expect(tester.widget(loadingOverlay).ignoring, isTrue); + expect(tester.getSize(loadingOverlay), const Size(40, 40)); expect( tester .widget( @@ -315,39 +329,51 @@ void main() { .color, Colors.white, ); - expect( - tester - .widget( - find.descendant( - of: loadingOverlay, - matching: find.byType(ColoredBox), - ), - ) - .color, - AppColors.mediaBlack, - ); final loadingSurface = tester.widget( find.byKey(const Key('player-loading-surface')), ); final decoration = loadingSurface.decoration! as BoxDecoration; expect(decoration.color, AppColors.refreshIndicatorSurface); expect(decoration.borderRadius, BorderRadius.circular(20)); + expect( + find.descendant(of: loadingOverlay, matching: find.byType(ColoredBox)), + findsNothing, + ); - // IgnorePointer 让加载中的播放暂停与上下滑仍能透传。 + expect(find.byKey(const Key('player-back')).hitTestable(), findsOneWidget); + expect( + find.byKey(const Key('player-episode-heading')).hitTestable(), + findsOneWidget, + ); + expect( + find.byKey(const Key('player-drama-title')).hitTestable(), + findsOneWidget, + ); + expect( + find + .descendant( + of: find.byKey(const Key('player-rail-like')), + matching: find.byType(IconButton), + ) + .hitTestable(), + findsOneWidget, + ); + expect(find.byKey(const Key('player-progress')), findsOneWidget); + expect(find.text('00:00 / 01:00'), findsOneWidget); + + // IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。 expect(fullLogic.session.userWantsPlay, isTrue); await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable()); await tester.pump(); expect(fullLogic.session.userWantsPlay, isFalse); final indexBeforeSwipe = fullLogic.session.currentIndex; - await tester.drag( - fullPlayerPage, - const Offset(0, -560), - ); + await tester.drag(fullPlayerPage, const Offset(0, -560)); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(fullLogic.session.currentIndex, isNot(indexBeforeSwipe)); + expect(tester.state(fullCurrentSurface), same(fullCurrentSurfaceState)); - // IgnorePointer 让加载中的返回按钮仍可点击。 + // 切集完成后返回按钮仍可正常点击。 await tester.pumpAndSettle(); expect(loadingOverlay, findsNothing); await tester.tap(find.byKey(const Key('player-back')).hitTestable()); @@ -1900,6 +1926,15 @@ class _GatedPlayer implements PlayerPort { Future dispose() async {} } +class _GatedVideoPlayer extends VideoPlayerPort { + _GatedVideoPlayer(super.episode, this.initialization); + + final Future initialization; + + @override + Future initialize() => initialization; +} + const _lockedDrama = Drama( id: 'locked-demo', title: 'Locked Demo', diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index ed400a2..19a5681 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -113,6 +113,7 @@ Drama _drama( number: index + 1, title: 'Episode ${index + 1}', isFree: true, + durationSec: 60, videoUrl: 'https://example.test/$format-$index.mp4', likeCount: likeCount, commentCount: commentCount, From 63296d2a929501db81657b24fb11a2379969dd45 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:05:41 +0800 Subject: [PATCH 27/42] =?UTF-8?q?fix(player):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=92=AD=E6=94=BE=E9=A1=B5=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E7=82=B9=E8=B5=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 3 + .../player/widgets/home_feed_overlay.dart | 139 +------------ .../widgets/player_double_tap_hearts.dart | 2 + lib/pages/player/widgets/player_overlay.dart | 20 +- .../player/widgets/player_overlay_models.dart | 6 + .../widgets/player_tap_gesture_region.dart | 192 ++++++++++++++++++ test/player/double_tap_like_test.dart | 37 +++- test/player/player_fullscreen_test.dart | 94 ++++++++- 8 files changed, 348 insertions(+), 145 deletions(-) create mode 100644 lib/pages/player/widgets/player_tap_gesture_region.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 355bb62..1318d93 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1228,6 +1228,9 @@ class _PlayerPageState extends State ? null : () => unawaited(logic.leave(context)), onTogglePlay: _togglePlayback, + onDoubleTapLike: locked + ? null + : (_) => _handleDoubleTapLike(context, interactions, episode), onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), onDetail: () => unawaited(logic.openDetail(context)), diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 467cb53..2fd6fc0 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -14,7 +14,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; -import 'player_double_tap_hearts.dart'; +import 'player_tap_gesture_region.dart'; import 'seek_progress_bar.dart'; import 'scrub_preview_card.dart'; @@ -110,19 +110,8 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { - static const Duration _singleTapDelay = Duration(milliseconds: 400); - bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; - Timer? _singleTapTimer; - DateTime? _lastDownAt; - DateTime? _blockSingleTapUntil; - bool _doubleTapDown = false; - bool _likedThisPair = false; - int _downSerial = 0; - int _tapSeq = 0; - int _nextHeartBurstId = 0; - final List<({int id, Offset position})> _heartBursts = []; @override void didUpdateWidget(covariant HomeFeedOverlay oldWidget) { @@ -132,13 +121,6 @@ class _HomeFeedOverlayState extends State { } } - @override - void dispose() { - _tapSeq++; - _singleTapTimer?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { return SafeArea( @@ -146,21 +128,16 @@ class _HomeFeedOverlayState extends State { child: Stack( children: [ Positioned.fill( - child: LayoutBuilder( - builder: (context, constraints) => Listener( - onPointerUp: (_) => widget.onBoostEnd(), - onPointerCancel: (_) => widget.onBoostEnd(), - onPointerDown: _handlePointerDown, - child: GestureDetector( - key: const Key('home-feed-gesture'), - behavior: HitTestBehavior.opaque, - onTapUp: _handleTapUp, - onLongPressStart: (details) => - _handleLongPressStart(details, constraints.maxWidth), - onLongPressEnd: (_) => widget.onBoostEnd(), - onLongPressCancel: widget.onBoostEnd, - ), - ), + child: PlayerTapGestureRegion( + gestureKey: const Key('home-feed-gesture'), + episodeId: widget.episode.id, + enabled: !_scrubbing, + doubleTapEnabled: !widget.locked, + now: widget.now, + onSingleTap: widget.onTogglePlay, + onDoubleTapLike: widget.onDoubleTapLike, + onLongPressStart: _handleLongPressStart, + onInteractionEnd: widget.onBoostEnd, ), ), Positioned( @@ -222,25 +199,6 @@ class _HomeFeedOverlayState extends State { onSeek: widget.onSeek, ), ), - if (_heartBursts.isNotEmpty) - Positioned.fill( - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final burst in _heartBursts) - Positioned( - left: burst.position.dx, - top: burst.position.dy - 10, - child: PlayerDoubleTapHeartBurst( - key: ValueKey(burst.id), - onCompleted: () => _removeHeartBurst(burst.id), - ), - ), - ], - ), - ), - ), ], ), ); @@ -262,83 +220,8 @@ class _HomeFeedOverlayState extends State { widget.onScrubEnd?.call(wasVisible); } - DateTime _now() => widget.now?.call() ?? DateTime.now(); - - void _handlePointerDown(PointerDownEvent event) { - final now = _now(); - final previous = _lastDownAt; - _downSerial++; - _lastDownAt = now; - final repeated = - previous != null && now.difference(previous) <= _singleTapDelay; - if (!repeated) { - if (_singleTapBlocked(now)) return; - _doubleTapDown = false; - _likedThisPair = false; - return; - } - _doubleTapDown = true; - _blockSingleTapUntil = now.add(_singleTapDelay); - _cancelPendingSingleTap(); - } - - bool _singleTapBlocked(DateTime now) { - final blockedUntil = _blockSingleTapUntil; - return blockedUntil != null && now.isBefore(blockedUntil); - } - - void _cancelPendingSingleTap() { - _tapSeq++; - _singleTapTimer?.cancel(); - _singleTapTimer = null; - } - - void _handleTapUp(TapUpDetails details) { - final now = _now(); - if (_doubleTapDown || _singleTapBlocked(now)) { - _showDoubleTapLike(details.localPosition); - return; - } - final serial = _downSerial; - final tapSeq = ++_tapSeq; - _singleTapTimer?.cancel(); - late final Timer pending; - pending = Timer(_singleTapDelay, () { - if (!identical(_singleTapTimer, pending)) return; - _singleTapTimer = null; - if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return; - if (_doubleTapDown || _singleTapBlocked(_now())) return; - _lastDownAt = null; - widget.onTogglePlay(); - }); - _singleTapTimer = pending; - } - - void _showDoubleTapLike(Offset position) { - if (_likedThisPair) return; - _likedThisPair = true; - _blockSingleTapUntil = _now().add(_singleTapDelay); - _cancelPendingSingleTap(); - _showHearts(position); - } - - void _showHearts(Offset position) { - final id = _nextHeartBurstId++; - setState(() => _heartBursts.add((id: id, position: position))); - widget.onDoubleTapLike?.call(position); - } - - void _removeHeartBurst(int id) { - if (!mounted) return; - setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); - } - void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; - _tapSeq++; - _cancelPendingSingleTap(); - _doubleTapDown = false; - _lastDownAt = null; final dx = details.localPosition.dx; if (dx < third || dx > width - third) { widget.onBoostStart(); diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 568c86e..b071442 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -37,6 +37,8 @@ const List<_HeartParticle> _hearts = [ class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + static const int particleCount = 3; + final VoidCallback onCompleted; @override diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index d5f90b8..ebf9e54 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -14,6 +14,7 @@ import '../../../l10n/app_localizations.dart'; import 'player_center_toggle.dart'; import 'interaction_rail.dart'; import 'player_overlay_models.dart'; +import 'player_tap_gesture_region.dart'; import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; @@ -56,16 +57,15 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: LayoutBuilder( - builder: (context, constraints) => GestureDetector( - key: const Key('player-video-tap'), - behavior: HitTestBehavior.opaque, - onTap: actions.onTogglePlay, - onLongPressStart: (details) => - _handleLongPressStart(details, constraints.maxWidth), - onLongPressEnd: (_) => actions.onBoostEnd(), - onLongPressCancel: actions.onBoostEnd, - ), + child: PlayerTapGestureRegion( + gestureKey: const Key('player-video-tap'), + episodeId: data.episode.id, + enabled: !scrubbing, + doubleTapEnabled: !data.locked, + onSingleTap: actions.onTogglePlay, + onDoubleTapLike: actions.onDoubleTapLike, + onLongPressStart: _handleLongPressStart, + onInteractionEnd: actions.onBoostEnd, ), ), _top(context), diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 0e4cfd4..59399bb 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import 'package:video_player/video_player.dart'; @@ -87,6 +89,7 @@ class PlayerOverlayActions { const PlayerOverlayActions({ required this.onBack, required this.onTogglePlay, + this.onDoubleTapLike, required this.onClear, required this.onExitClear, required this.onDetail, @@ -111,6 +114,9 @@ class PlayerOverlayActions { /// 切换播放状态。 final VoidCallback onTogglePlay; + /// 双击画面点赞;位置用于飘心。 + final ValueChanged? onDoubleTapLike; + /// 进入清屏。 final VoidCallback onClear; diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart new file mode 100644 index 0000000..d28fcf7 --- /dev/null +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -0,0 +1,192 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'player_double_tap_hearts.dart'; + +typedef PlayerLongPressStartCallback = + void Function(LongPressStartDetails details, double width); + +/// 播放画面共用的单击/双击仲裁层。 +class PlayerTapGestureRegion extends StatefulWidget { + const PlayerTapGestureRegion({ + super.key, + required this.gestureKey, + required this.episodeId, + required this.onSingleTap, + required this.onLongPressStart, + required this.onInteractionEnd, + this.onDoubleTapLike, + this.doubleTapEnabled = true, + this.enabled = true, + this.now, + }); + + final Key gestureKey; + final String episodeId; + final VoidCallback onSingleTap; + final ValueChanged? onDoubleTapLike; + final PlayerLongPressStartCallback onLongPressStart; + final VoidCallback onInteractionEnd; + final bool doubleTapEnabled; + final bool enabled; + final DateTime Function()? now; + + @override + State createState() => _PlayerTapGestureRegionState(); +} + +class _PlayerTapGestureRegionState extends State { + static const Duration _singleTapDelay = Duration(milliseconds: 320); + + Timer? _singleTapTimer; + DateTime? _lastDownAt; + DateTime? _blockSingleTapUntil; + bool _doubleTapDown = false; + bool _likedThisPair = false; + int _downSerial = 0; + int _tapSeq = 0; + int _nextHeartBurstId = 0; + final List<({int id, Offset position})> _heartBursts = []; + + @override + void dispose() { + _tapSeq++; + _singleTapTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: !widget.enabled, + child: LayoutBuilder( + builder: (context, constraints) => Listener( + onPointerUp: (_) => widget.onInteractionEnd(), + onPointerCancel: (_) => widget.onInteractionEnd(), + onPointerDown: _handlePointerDown, + child: Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + key: widget.gestureKey, + behavior: HitTestBehavior.opaque, + onTapUp: _handleTapUp, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => widget.onInteractionEnd(), + onLongPressCancel: widget.onInteractionEnd, + ), + if (_heartBursts.isNotEmpty) + Positioned.fill( + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final burst in _heartBursts) + Positioned( + left: burst.position.dx, + top: burst.position.dy - 10, + child: PlayerDoubleTapHeartBurst( + key: ValueKey(burst.id), + onCompleted: () => _removeHeartBurst(burst.id), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + DateTime _now() => widget.now?.call() ?? DateTime.now(); + + void _handlePointerDown(PointerDownEvent event) { + final now = _now(); + final previous = _lastDownAt; + _downSerial++; + _lastDownAt = now; + final repeated = + previous != null && now.difference(previous) <= _singleTapDelay; + if (!repeated) { + if (_singleTapBlocked(now)) return; + _doubleTapDown = false; + _likedThisPair = false; + return; + } + _doubleTapDown = true; + _blockSingleTapUntil = now.add(_singleTapDelay); + _cancelPendingSingleTap(); + } + + bool _singleTapBlocked(DateTime now) { + final blockedUntil = _blockSingleTapUntil; + return blockedUntil != null && now.isBefore(blockedUntil); + } + + void _cancelPendingSingleTap() { + _tapSeq++; + _singleTapTimer?.cancel(); + _singleTapTimer = null; + } + + void _handleTapUp(TapUpDetails details) { + final now = _now(); + if (_doubleTapDown || _singleTapBlocked(now)) { + _handleDoubleTapLike(details.localPosition); + return; + } + final serial = _downSerial; + final tapSeq = ++_tapSeq; + _singleTapTimer?.cancel(); + late final Timer pending; + pending = Timer(_singleTapDelay, () { + if (!identical(_singleTapTimer, pending)) return; + _singleTapTimer = null; + if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return; + if (_doubleTapDown || _singleTapBlocked(_now())) return; + _lastDownAt = null; + widget.onSingleTap(); + }); + _singleTapTimer = pending; + } + + void _handleDoubleTapLike(Offset position) { + _blockSingleTapUntil = _now().add(_singleTapDelay); + _cancelPendingSingleTap(); + if (!widget.doubleTapEnabled || _likedThisPair) return; + _likedThisPair = true; + final id = _nextHeartBurstId++; + setState(() => _heartBursts.add((id: id, position: position))); + if (kDebugMode) { + debugPrint( + '[player] phase=double-tap-like episodeId=${widget.episodeId}', + ); + debugPrint( + '[player] phase=heart-burst ' + 'count=${PlayerDoubleTapHeartBurst.particleCount}', + ); + } + widget.onDoubleTapLike?.call(position); + } + + void _removeHeartBurst(int id) { + if (!mounted) return; + setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + } + + void _handleLongPressStart(LongPressStartDetails details, double width) { + _tapSeq++; + _cancelPendingSingleTap(); + _doubleTapDown = false; + _lastDownAt = null; + widget.onLongPressStart(details, width); + } +} diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index a977bbc..cc65410 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -12,8 +12,8 @@ import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { - testWidgets('double tap within 400ms never pauses', (tester) async { - for (final delay in [120, 250, 300, 380]) { + testWidgets('double tap within 320ms never pauses', (tester) async { + for (final delay in [120, 250, 300]) { final repository = FakeInteractionRepository(); final haptics = FakeHaptics(); final key = GlobalKey<_HarnessState>(); @@ -74,6 +74,39 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); expect(key.currentState!.pauseToggles, 2); }); + + testWidgets('liked episode keeps heart feedback without liking again', ( + tester, + ) async { + final repository = FakeInteractionRepository(); + final haptics = FakeHaptics(); + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness(tester, repository, haptics, key, clock); + final center = tester.getCenter(find.byKey(const Key('home-feed-gesture'))); + + await tester.tapAt(center); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(center); + await tester.pump(); + expect(repository.likeTargets, hasLength(1)); + await tester.pump(const Duration(milliseconds: 800)); + + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(center); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(repository.likeTargets, hasLength(1)); + expect(haptics.lightImpacts, 2); + expect(key.currentState!.pauseToggles, 0); + expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + }); } Future _pumpHarness( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 915cadd..54a1d91 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -364,7 +364,7 @@ void main() { // IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。 expect(fullLogic.session.userWantsPlay, isTrue); await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable()); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(fullLogic.session.userWantsPlay, isFalse); final indexBeforeSwipe = fullLogic.session.currentIndex; await tester.drag(fullPlayerPage, const Offset(0, -560)); @@ -632,7 +632,7 @@ void main() { expect(find.byKey(const Key('player-play-toggle')), findsNothing); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect( tester @@ -655,6 +655,82 @@ void main() { expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); + testWidgets('should_double_tap_like_once_without_pausing_full_player', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + final interactions = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 23000), + ); + await _pumpApp(tester, interactions: interactions); + await _openSeriesPlayer(tester); + + final logic = _pageLogic(tester, find.byType(PlayerPage).last); + final gesture = find.byKey(const Key('player-video-tap')).hitTestable(); + final position = tester.getCenter(gesture); + expect(logic.session.userWantsPlay, isTrue); + + for (var index = 0; index < 4; index++) { + await tester.tapAt(position); + } + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(logic.session.userWantsPlay, isTrue); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']); + for (var index = 0; index < 3; index++) { + expect(find.byKey(Key('double-tap-heart-$index')), findsOneWidget); + } + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('player-rail-like')), + matching: find.byType(Image), + ), + ) + .color, + AppColors.doubleTapHeartRed, + ); + + await tester.pump(const Duration(milliseconds: 800)); + expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); + expect(logic.session.userWantsPlay, isTrue); + }); + + testWidgets('should_double_tap_like_in_clear_screen', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + final interactions = FakeInteractionRepository(); + await _pumpApp(tester, interactions: interactions); + await _openSeriesPlayer(tester); + final logic = _pageLogic(tester, find.byType(PlayerPage).last); + + await _clearFromMore(tester); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(logic.session.userWantsPlay, isTrue); + expect(interactions.likeTargets, hasLength(1)); + expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + expect(find.byKey(const Key('double-tap-heart-1')), findsOneWidget); + expect(find.byKey(const Key('double-tap-heart-2')), findsOneWidget); + }); + testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( tester, ) async { @@ -666,7 +742,7 @@ void main() { expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( tester @@ -710,13 +786,13 @@ void main() { ); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byType(PlayerCenterToggle), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); @@ -1649,8 +1725,10 @@ void main() { testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { + final interactions = FakeInteractionRepository(); await _pumpApp( tester, + interactions: interactions, playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'city-lights-demo-1', @@ -1663,6 +1741,12 @@ void main() { await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget); + await tester.tapAt(const Offset(80, 300)); + await tester.tapAt(const Offset(80, 300)); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); + expect(interactions.likeTargets, isEmpty); + await tester.tap(find.byKey(const Key('player-open-unlock'))); await tester.pumpAndSettle(); From bea50ab32faf8aa2b538a8a76697f59cc2a54cdf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:11:01 +0800 Subject: [PATCH 28/42] =?UTF-8?q?fix(player):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E9=A3=98=E5=BF=83=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 3 ++ .../player/widgets/home_feed_overlay.dart | 1 + .../widgets/player_double_tap_hearts.dart | 29 +++++++++++-------- lib/pages/player/widgets/player_overlay.dart | 1 + .../widgets/player_tap_gesture_region.dart | 1 + test/player/double_tap_like_test.dart | 2 +- .../player/player_double_tap_hearts_test.dart | 29 ++++++++++++++++--- test/player/player_fullscreen_test.dart | 6 ++-- 8 files changed, 52 insertions(+), 20 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 1318d93..49cca34 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -639,6 +639,7 @@ class _PlayerPageState extends State ); return Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ PlayerRenderStack( pageController: _pageController, @@ -662,6 +663,7 @@ class _PlayerPageState extends State ); final body = Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ content, if (widget.homeMode && @@ -971,6 +973,7 @@ class _PlayerPageState extends State return Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ if (selected && !entryLoading && diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 2fd6fc0..ce6dc4f 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -126,6 +126,7 @@ class _HomeFeedOverlayState extends State { return SafeArea( bottom: false, child: Stack( + clipBehavior: Clip.none, children: [ Positioned.fill( child: PlayerTapGestureRegion( diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index b071442..3cd039b 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -4,31 +4,32 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const int _heartDurationMs = 680; +const int _heartDurationMs = 1300; +const int _lastHeartDelayMs = 200; const double _heartPopPortion = 0.18; const double _heartStartScale = 0.7; -const double _heartHoldPortion = 0.45; +const double _heartHoldPortion = 0.65; const List<_HeartParticle> _hearts = [ _HeartParticle( delay: 0, - travel: 108, + travel: 175, drift: 0, - size: 48, + size: 44, color: AppColors.doubleTapHeartRed, ), _HeartParticle( - delay: 50, - travel: 96, - drift: -28, - size: 36, + delay: 110, + travel: 150, + drift: -32, + size: 34, color: AppColors.doubleTapHeartPink, ), _HeartParticle( - delay: 90, - travel: 118, - drift: 26, - size: 40, + delay: _lastHeartDelayMs, + travel: 165, + drift: 30, + size: 38, color: AppColors.doubleTapHeartRed, ), ]; @@ -38,6 +39,10 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); static const int particleCount = 3; + static const int singleHeartDurationMs = _heartDurationMs; + static const Duration animationDuration = Duration( + milliseconds: _lastHeartDelayMs + _heartDurationMs, + ); final VoidCallback onCompleted; diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index ebf9e54..0e187e3 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -55,6 +55,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { Widget build(BuildContext context) { return SafeArea( child: Stack( + clipBehavior: Clip.none, children: [ Positioned.fill( child: PlayerTapGestureRegion( diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index d28fcf7..32a2132 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -70,6 +70,7 @@ class _PlayerTapGestureRegionState extends State { onPointerDown: _handlePointerDown, child: Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ GestureDetector( key: widget.gestureKey, diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index cc65410..17ee145 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -91,7 +91,7 @@ void main() { await tester.tapAt(center); await tester.pump(); expect(repository.likeTargets, hasLength(1)); - await tester.pump(const Duration(milliseconds: 800)); + await tester.pump(const Duration(milliseconds: 1600)); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 420e3f4..f22e9dd 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -13,12 +13,33 @@ void main() { home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), ), ); - await tester.pump(const Duration(milliseconds: 160)); + await tester.pump(const Duration(milliseconds: 300)); expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); - expect(_size(tester, 0), 48); - expect(_size(tester, 1), 36); - expect(_size(tester, 2), 40); + expect(_size(tester, 0), 44); + expect(_size(tester, 1), 34); + expect(_size(tester, 2), 38); + expect( + tester + .widgetList( + find.descendant( + of: find.byType(PlayerDoubleTapHeartBurst), + matching: find.byType(Stack), + ), + ) + .every((stack) => stack.clipBehavior == Clip.none), + isTrue, + ); + }); + + test('heart burst stays long and sparse', () { + expect(PlayerDoubleTapHeartBurst.singleHeartDurationMs, 1300); + expect( + PlayerDoubleTapHeartBurst.animationDuration, + greaterThanOrEqualTo(const Duration(milliseconds: 1200)), + ); + expect(PlayerDoubleTapHeartBurst.particleCount, lessThanOrEqualTo(4)); + expect(PlayerDoubleTapHeartBurst.particleCount, 3); }); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 54a1d91..84cab43 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -679,7 +679,7 @@ void main() { await tester.tapAt(position); } await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 300)); expect(logic.session.userWantsPlay, isTrue); expect(find.byKey(const Key('player-play-toggle')), findsNothing); @@ -699,7 +699,7 @@ void main() { AppColors.doubleTapHeartRed, ); - await tester.pump(const Duration(milliseconds: 800)); + await tester.pump(const Duration(milliseconds: 1600)); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(logic.session.userWantsPlay, isTrue); }); @@ -722,7 +722,7 @@ void main() { await tester.tapAt(const Offset(80, 300)); await tester.tapAt(const Offset(80, 300)); await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 300)); expect(logic.session.userWantsPlay, isTrue); expect(interactions.likeTargets, hasLength(1)); From 94b37ef00f7d2810b0a65f1ac40da954cc5fd042 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:16:06 +0800 Subject: [PATCH 29/42] =?UTF-8?q?fix(player):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E9=A3=98=E5=BF=83=E8=BD=A8=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/home_feed_overlay.dart | 1 + .../widgets/player_double_tap_hearts.dart | 52 +++++++++++++++---- lib/pages/player/widgets/player_overlay.dart | 1 + .../widgets/player_tap_gesture_region.dart | 1 + test/player/double_tap_like_test.dart | 40 +++++++++++++- .../player/player_double_tap_hearts_test.dart | 8 +-- test/player/player_fullscreen_test.dart | 2 +- 7 files changed, 89 insertions(+), 16 deletions(-) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index ce6dc4f..29232b9 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -130,6 +130,7 @@ class _HomeFeedOverlayState extends State { children: [ Positioned.fill( child: PlayerTapGestureRegion( + key: const ValueKey('home-feed-tap-gesture-region'), gestureKey: const Key('home-feed-gesture'), episodeId: widget.episode.id, enabled: !_scrubbing, diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 3cd039b..51cf480 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,33 +1,34 @@ // Author: fruit.com.cn +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const int _heartDurationMs = 1300; -const int _lastHeartDelayMs = 200; -const double _heartPopPortion = 0.18; +const int _heartDurationMs = 1900; +const int _lastHeartDelayMs = 260; +const double _heartPopPortion = 0.12; const double _heartStartScale = 0.7; -const double _heartHoldPortion = 0.65; +const double _heartHoldPortion = 0.45; const List<_HeartParticle> _hearts = [ _HeartParticle( delay: 0, - travel: 175, + travel: 120, drift: 0, size: 44, color: AppColors.doubleTapHeartRed, ), _HeartParticle( - delay: 110, - travel: 150, + delay: 140, + travel: 120, drift: -32, size: 34, color: AppColors.doubleTapHeartPink, ), _HeartParticle( delay: _lastHeartDelayMs, - travel: 165, + travel: 120, drift: 30, size: 38, color: AppColors.doubleTapHeartRed, @@ -36,7 +37,11 @@ const List<_HeartParticle> _hearts = [ /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { - const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + const PlayerDoubleTapHeartBurst({ + super.key, + required this.burstId, + required this.onCompleted, + }); static const int particleCount = 3; static const int singleHeartDurationMs = _heartDurationMs; @@ -44,6 +49,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { milliseconds: _lastHeartDelayMs + _heartDurationMs, ); + final int burstId; final VoidCallback onCompleted; @override @@ -54,11 +60,17 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); + _lifetime.start(); + if (kDebugMode) { + debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); + debugPrint('[player] phase=heart-burst-start id=${widget.burstId}'); + } final total = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); @@ -72,11 +84,26 @@ class _PlayerDoubleTapHeartBurstState extends State void _onStatus(AnimationStatus status) { if (status != AnimationStatus.completed || _completed) return; _completed = true; + if (kDebugMode) { + final elapsedMs = _controller.duration?.inMilliseconds ?? 0; + debugPrint( + '[player] phase=heart-burst-complete ' + 'id=${widget.burstId} elapsedMs=$elapsedMs', + ); + } widget.onCompleted(); } @override void dispose() { + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-dispose ' + 'id=${widget.burstId} elapsedMs=${_lifetime.elapsedMilliseconds} ' + 'completed=${_completed ? 1 : 0}', + ); + } + _lifetime.stop(); _controller ..removeStatusListener(_onStatus) ..dispose(); @@ -111,14 +138,17 @@ class _PlayerDoubleTapHeartBurstState extends State 1.0, ); if (progress <= 0) return const SizedBox.shrink(); - final move = Curves.easeOutCubic.transform(progress); + final move = Curves.easeOutSine.transform(progress); final scale = progress < _heartPopPortion ? _heartStartScale + (1 - _heartStartScale) * (progress / _heartPopPortion) : 1.0; final opacity = progress < _heartHoldPortion ? 1.0 - : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); + : 1 - + Curves.easeInOutSine.transform( + (progress - _heartHoldPortion) / (1 - _heartHoldPortion), + ); return Positioned( left: heart.drift * move - heart.size / 2, top: -heart.size / 2 - heart.travel * move, diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 0e187e3..8d3f5f1 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -59,6 +59,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { children: [ Positioned.fill( child: PlayerTapGestureRegion( + key: const ValueKey('player-tap-gesture-region'), gestureKey: const Key('player-video-tap'), episodeId: data.episode.id, enabled: !scrubbing, diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 32a2132..f053a83 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -93,6 +93,7 @@ class _PlayerTapGestureRegionState extends State { top: burst.position.dy - 10, child: PlayerDoubleTapHeartBurst( key: ValueKey(burst.id), + burstId: burst.id, onCompleted: () => _removeHeartBurst(burst.id), ), ), diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 17ee145..9463851 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -6,6 +6,8 @@ import 'package:fruitshorts/core/data/services/interaction_controller.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; +import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; +import 'package:fruitshorts/pages/player/widgets/player_tap_gesture_region.dart'; import '../support/fake_catalog.dart'; import '../support/fake_haptics.dart'; @@ -91,7 +93,7 @@ void main() { await tester.tapAt(center); await tester.pump(); expect(repository.likeTargets, hasLength(1)); - await tester.pump(const Duration(milliseconds: 1600)); + await tester.pump(const Duration(milliseconds: 2300)); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); @@ -107,6 +109,40 @@ void main() { expect(key.currentState!.pauseToggles, 0); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); + + testWidgets('parent rebuild keeps the heart burst state alive', ( + tester, + ) async { + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + key, + clock, + ); + final center = tester.getCenter(find.byKey(const Key('home-feed-gesture'))); + + await tester.tapAt(center); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final gestureFinder = find.byType(PlayerTapGestureRegion); + final burstFinder = find.byType(PlayerDoubleTapHeartBurst); + final gestureState = tester.state(gestureFinder); + final burstState = tester.state(burstFinder); + + key.currentState!.rebuild(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.state(gestureFinder), same(gestureState)); + expect(tester.state(burstFinder), same(burstState)); + expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + }); } Future _pumpHarness( @@ -155,6 +191,8 @@ class _HarnessState extends State<_Harness> { ); int pauseToggles = 0; + void rebuild() => setState(() {}); + Future _like({bool likeOnly = false}) async { await widget.haptics.lightImpact(); final episodeId = demoDramas.first.episodes.first.id; diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index f22e9dd..cc1a097 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -10,7 +10,9 @@ void main() { ) async { await tester.pumpWidget( const MaterialApp( - home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + home: Scaffold( + body: PlayerDoubleTapHeartBurst(burstId: 1, onCompleted: _noop), + ), ), ); await tester.pump(const Duration(milliseconds: 300)); @@ -33,10 +35,10 @@ void main() { }); test('heart burst stays long and sparse', () { - expect(PlayerDoubleTapHeartBurst.singleHeartDurationMs, 1300); + expect(PlayerDoubleTapHeartBurst.singleHeartDurationMs, 1900); expect( PlayerDoubleTapHeartBurst.animationDuration, - greaterThanOrEqualTo(const Duration(milliseconds: 1200)), + greaterThanOrEqualTo(const Duration(milliseconds: 1800)), ); expect(PlayerDoubleTapHeartBurst.particleCount, lessThanOrEqualTo(4)); expect(PlayerDoubleTapHeartBurst.particleCount, 3); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 84cab43..1c4a022 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -699,7 +699,7 @@ void main() { AppColors.doubleTapHeartRed, ); - await tester.pump(const Duration(milliseconds: 1600)); + await tester.pump(const Duration(milliseconds: 2300)); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(logic.session.userWantsPlay, isTrue); }); From f3dd4a9b5c380c73147089034c0e26ca167763a5 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:19:39 +0800 Subject: [PATCH 30/42] =?UTF-8?q?fix(player):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E9=A3=98=E5=BF=83=E7=B2=92=E5=AD=90=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 124 +++++++++--------- .../widgets/player_tap_gesture_region.dart | 4 - .../player/player_double_tap_hearts_test.dart | 38 ++++-- 3 files changed, 87 insertions(+), 79 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 51cf480..c0efa04 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,56 +1,38 @@ // Author: fruit.com.cn +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const int _heartDurationMs = 1900; -const int _lastHeartDelayMs = 260; -const double _heartPopPortion = 0.12; -const double _heartStartScale = 0.7; -const double _heartHoldPortion = 0.45; - -const List<_HeartParticle> _hearts = [ - _HeartParticle( - delay: 0, - travel: 120, - drift: 0, - size: 44, - color: AppColors.doubleTapHeartRed, - ), - _HeartParticle( - delay: 140, - travel: 120, - drift: -32, - size: 34, - color: AppColors.doubleTapHeartPink, - ), - _HeartParticle( - delay: _lastHeartDelayMs, - travel: 120, - drift: 30, - size: 38, - color: AppColors.doubleTapHeartRed, - ), -]; - /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({ super.key, required this.burstId, required this.onCompleted, + this.random, }); - static const int particleCount = 3; - static const int singleHeartDurationMs = _heartDurationMs; - static const Duration animationDuration = Duration( - milliseconds: _lastHeartDelayMs + _heartDurationMs, - ); + static const int minParticleCount = 4; + static const int maxParticleCount = 5; final int burstId; final VoidCallback onCompleted; + final Random? random; + + @visibleForTesting + static double opacityForProgress(double progress) => + 1 - Curves.easeIn.transform(progress); + + @visibleForTesting + static double scaleForProgress(double progress) { + return progress < 0.3 + ? 0.55 + (1.15 - 0.55) * (progress / 0.3) + : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); + } @override State createState() => @@ -60,6 +42,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + late final List<_HeartParticle> _particles; final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @@ -67,13 +50,39 @@ class _PlayerDoubleTapHeartBurstState extends State void initState() { super.initState(); _lifetime.start(); + final random = widget.random ?? Random(); + var delay = 0; + _particles = List.generate( + PlayerDoubleTapHeartBurst.minParticleCount + + random.nextInt( + PlayerDoubleTapHeartBurst.maxParticleCount - + PlayerDoubleTapHeartBurst.minParticleCount + + 1, + ), + (index) { + if (index > 0) delay += 40 + random.nextInt(21); + return _HeartParticle( + delay: delay, + duration: 900 + random.nextInt(201), + travel: 120 + random.nextInt(41), + drift: -30 + random.nextInt(61), + size: (22 + random.nextInt(13)).toDouble(), + color: random.nextBool() + ? AppColors.doubleTapHeartRed + : AppColors.doubleTapHeartPink, + ); + }, + ); + final total = _particles + .map((particle) => particle.delay + particle.duration) + .reduce(max); if (kDebugMode) { debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); - debugPrint('[player] phase=heart-burst-start id=${widget.burstId}'); + debugPrint( + '[player] phase=heart-burst-start ' + 'id=${widget.burstId} count=${_particles.length}', + ); } - final total = _hearts - .map((heart) => heart.delay + _heartDurationMs) - .reduce((left, right) => left > right ? left : right); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), @@ -122,8 +131,8 @@ class _PlayerDoubleTapHeartBurstState extends State return Stack( clipBehavior: Clip.none, children: [ - for (var index = 0; index < _hearts.length; index++) - _heart(_hearts[index], elapsed, index), + for (var index = 0; index < _particles.length; index++) + _particle(_particles[index], elapsed, index), ], ); }, @@ -132,35 +141,26 @@ class _PlayerDoubleTapHeartBurstState extends State ); } - Widget _heart(_HeartParticle heart, double elapsed, int index) { - final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( + Widget _particle(_HeartParticle particle, double elapsed, int index) { + final progress = ((elapsed - particle.delay) / particle.duration).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); - final move = Curves.easeOutSine.transform(progress); - final scale = progress < _heartPopPortion - ? _heartStartScale + - (1 - _heartStartScale) * (progress / _heartPopPortion) - : 1.0; - final opacity = progress < _heartHoldPortion - ? 1.0 - : 1 - - Curves.easeInOutSine.transform( - (progress - _heartHoldPortion) / (1 - _heartHoldPortion), - ); + final move = Curves.easeOutCubic.transform(progress); + final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); return Positioned( - left: heart.drift * move - heart.size / 2, - top: -heart.size / 2 - heart.travel * move, + left: particle.drift * move - particle.size / 2, + top: -particle.travel * move, child: Opacity( - opacity: opacity.clamp(0.0, 1.0), + opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), child: Transform.scale( scale: scale, child: Icon( key: Key('double-tap-heart-$index'), Icons.favorite_rounded, - color: heart.color, - size: heart.size, + color: particle.color, + size: particle.size, ), ), ), @@ -171,6 +171,7 @@ class _PlayerDoubleTapHeartBurstState extends State class _HeartParticle { const _HeartParticle({ required this.delay, + required this.duration, required this.travel, required this.drift, required this.size, @@ -178,8 +179,9 @@ class _HeartParticle { }); final int delay; - final double travel; - final double drift; + final int duration; + final int travel; + final int drift; final double size; final Color color; } diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index f053a83..59e1cbd 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -171,10 +171,6 @@ class _PlayerTapGestureRegionState extends State { debugPrint( '[player] phase=double-tap-like episodeId=${widget.episodeId}', ); - debugPrint( - '[player] phase=heart-burst ' - 'count=${PlayerDoubleTapHeartBurst.particleCount}', - ); } widget.onDoubleTapLike?.call(position); } diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index cc1a097..3841031 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -1,26 +1,34 @@ // Author: fruit.com.cn +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; void main() { - testWidgets('should_show_three_medium_hearts_when_double_tap_burst_starts', ( + testWidgets('should_show_randomized_heart_particles_when_burst_starts', ( tester, ) async { await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst(burstId: 1, onCompleted: _noop), + body: PlayerDoubleTapHeartBurst( + burstId: 1, + onCompleted: _noop, + random: Random(42), + ), ), ), ); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 400)); - expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); - expect(_size(tester, 0), 44); - expect(_size(tester, 1), 34); - expect(_size(tester, 2), 38); + final hearts = find.byIcon(Icons.favorite_rounded); + final count = hearts.evaluate().length; + expect(count, inInclusiveRange(4, 5)); + for (var index = 0; index < count; index++) { + expect(_size(tester, index), inInclusiveRange(22, 34)); + } expect( tester .widgetList( @@ -34,14 +42,16 @@ void main() { ); }); - test('heart burst stays long and sparse', () { - expect(PlayerDoubleTapHeartBurst.singleHeartDurationMs, 1900); + test('heart particles fade from the start and scale in two phases', () { + expect(PlayerDoubleTapHeartBurst.opacityForProgress(0.5), lessThan(0.7)); + expect(PlayerDoubleTapHeartBurst.scaleForProgress(0), 0.55); expect( - PlayerDoubleTapHeartBurst.animationDuration, - greaterThanOrEqualTo(const Duration(milliseconds: 1800)), + PlayerDoubleTapHeartBurst.scaleForProgress(0.3), + closeTo(1.15, 1e-9), ); - expect(PlayerDoubleTapHeartBurst.particleCount, lessThanOrEqualTo(4)); - expect(PlayerDoubleTapHeartBurst.particleCount, 3); + expect(PlayerDoubleTapHeartBurst.scaleForProgress(1), closeTo(0.2, 1e-9)); + expect(PlayerDoubleTapHeartBurst.minParticleCount, 4); + expect(PlayerDoubleTapHeartBurst.maxParticleCount, 5); }); } From e7afd316f8d3cf6403329370df550d4ab1da1937 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:24:19 +0800 Subject: [PATCH 31/42] =?UTF-8?q?fix(player):=20=E6=94=BE=E6=85=A2?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E9=A3=98=E5=BF=83=E5=B9=B6=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 40 ++++++++++++++++--- .../player/player_double_tap_hearts_test.dart | 31 +++++++++++++- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index c0efa04..76d0d57 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -18,6 +18,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { static const int minParticleCount = 4; static const int maxParticleCount = 5; + static const int minimumBurstDurationMs = 2000; final int burstId; final VoidCallback onCompleted; @@ -60,11 +61,11 @@ class _PlayerDoubleTapHeartBurstState extends State 1, ), (index) { - if (index > 0) delay += 40 + random.nextInt(21); + if (index > 0) delay += 80 + random.nextInt(41); return _HeartParticle( delay: delay, - duration: 900 + random.nextInt(201), - travel: 120 + random.nextInt(41), + duration: 1500 + random.nextInt(301), + travel: 100 + random.nextInt(31), drift: -30 + random.nextInt(61), size: (22 + random.nextInt(13)).toDouble(), color: random.nextBool() @@ -73,9 +74,22 @@ class _PlayerDoubleTapHeartBurstState extends State ); }, ); - final total = _particles + var total = _particles .map((particle) => particle.delay + particle.duration) .reduce(max); + if (total < PlayerDoubleTapHeartBurst.minimumBurstDurationMs) { + final last = _particles.last; + final requiredDuration = + PlayerDoubleTapHeartBurst.minimumBurstDurationMs - last.delay; + final adjustedDuration = max( + last.duration, + requiredDuration, + ).clamp(1500, 1800).toInt(); + _particles[_particles.length - 1] = last.withDuration(adjustedDuration); + total = _particles + .map((particle) => particle.delay + particle.duration) + .reduce(max); + } if (kDebugMode) { debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); debugPrint( @@ -97,7 +111,7 @@ class _PlayerDoubleTapHeartBurstState extends State final elapsedMs = _controller.duration?.inMilliseconds ?? 0; debugPrint( '[player] phase=heart-burst-complete ' - 'id=${widget.burstId} elapsedMs=$elapsedMs', + 'id=${widget.burstId} configuredMs=$elapsedMs', ); } widget.onCompleted(); @@ -106,9 +120,12 @@ class _PlayerDoubleTapHeartBurstState extends State @override void dispose() { if (kDebugMode) { + final elapsedMs = _completed + ? _controller.duration?.inMilliseconds ?? 0 + : _lifetime.elapsedMilliseconds; debugPrint( '[player] phase=heart-burst-dispose ' - 'id=${widget.burstId} elapsedMs=${_lifetime.elapsedMilliseconds} ' + 'id=${widget.burstId} elapsedMs=$elapsedMs ' 'completed=${_completed ? 1 : 0}', ); } @@ -184,4 +201,15 @@ class _HeartParticle { final int drift; final double size; final Color color; + + _HeartParticle withDuration(int nextDuration) { + return _HeartParticle( + delay: delay, + duration: nextDuration, + travel: travel, + drift: drift, + size: size, + color: color, + ); + } } diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 3841031..98d7cc4 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -21,7 +21,7 @@ void main() { ), ), ); - await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 600)); final hearts = find.byIcon(Icons.favorite_rounded); final count = hearts.evaluate().length; @@ -53,6 +53,35 @@ void main() { expect(PlayerDoubleTapHeartBurst.minParticleCount, 4); expect(PlayerDoubleTapHeartBurst.maxParticleCount, 5); }); + + testWidgets('heart burst completes after at least two simulated seconds', ( + tester, + ) async { + var completed = false; + var elapsedMs = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlayerDoubleTapHeartBurst( + burstId: 2, + random: Random(7), + onCompleted: () => completed = true, + ), + ), + ), + ); + + while (!completed && elapsedMs < 4000) { + await tester.pump(const Duration(milliseconds: 16)); + elapsedMs += 16; + } + + expect(completed, isTrue); + expect(elapsedMs, greaterThanOrEqualTo(2000)); + expect(elapsedMs, lessThanOrEqualTo(2600)); + final count = find.byIcon(Icons.favorite_rounded).evaluate().length; + expect(count, inInclusiveRange(4, 5)); + }); } double _size(WidgetTester tester, int index) { From 30b43878ab27fd351428c04599bd0d4208779c08 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:29:48 +0800 Subject: [PATCH 32/42] =?UTF-8?q?fix(player):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=A0=B9Overlay=E6=B8=B2=E6=9F=93=E9=A3=98=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 8 +- .../widgets/player_tap_gesture_region.dart | 76 ++++++++++++------- test/player/double_tap_like_test.dart | 69 +++++++++++++++++ 3 files changed, 122 insertions(+), 31 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 76d0d57..baf482b 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -14,6 +14,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { required this.burstId, required this.onCompleted, this.random, + this.origin = Offset.zero, }); static const int minParticleCount = 4; @@ -23,6 +24,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { final int burstId; final VoidCallback onCompleted; final Random? random; + final Offset origin; @visibleForTesting static double opacityForProgress(double progress) => @@ -94,7 +96,9 @@ class _PlayerDoubleTapHeartBurstState extends State debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); debugPrint( '[player] phase=heart-burst-start ' - 'id=${widget.burstId} count=${_particles.length}', + 'id=${widget.burstId} count=${_particles.length} ' + 'at=${widget.origin.dx.toStringAsFixed(1)},' + '${widget.origin.dy.toStringAsFixed(1)}', ); } _controller = AnimationController( @@ -168,7 +172,7 @@ class _PlayerDoubleTapHeartBurstState extends State final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); return Positioned( left: particle.drift * move - particle.size / 2, - top: -particle.travel * move, + top: -particle.size / 2 - particle.travel * move, child: Opacity( opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), child: Transform.scale( diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 59e1cbd..8d36c2b 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -50,12 +50,18 @@ class _PlayerTapGestureRegionState extends State { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final List<({int id, Offset position})> _heartBursts = []; + final Map _heartOverlayEntries = {}; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); + for (final entry in _heartOverlayEntries.values.toList()) { + entry + ..remove() + ..dispose(); + } + _heartOverlayEntries.clear(); super.dispose(); } @@ -81,26 +87,6 @@ class _PlayerTapGestureRegionState extends State { onLongPressEnd: (_) => widget.onInteractionEnd(), onLongPressCancel: widget.onInteractionEnd, ), - if (_heartBursts.isNotEmpty) - Positioned.fill( - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final burst in _heartBursts) - Positioned( - left: burst.position.dx, - top: burst.position.dy - 10, - child: PlayerDoubleTapHeartBurst( - key: ValueKey(burst.id), - burstId: burst.id, - onCompleted: () => _removeHeartBurst(burst.id), - ), - ), - ], - ), - ), - ), ], ), ), @@ -142,7 +128,7 @@ class _PlayerTapGestureRegionState extends State { void _handleTapUp(TapUpDetails details) { final now = _now(); if (_doubleTapDown || _singleTapBlocked(now)) { - _handleDoubleTapLike(details.localPosition); + _handleDoubleTapLike(details.globalPosition); return; } final serial = _downSerial; @@ -160,24 +146,56 @@ class _PlayerTapGestureRegionState extends State { _singleTapTimer = pending; } - void _handleDoubleTapLike(Offset position) { + void _handleDoubleTapLike(Offset globalPosition) { _blockSingleTapUntil = _now().add(_singleTapDelay); _cancelPendingSingleTap(); if (!widget.doubleTapEnabled || _likedThisPair) return; _likedThisPair = true; - final id = _nextHeartBurstId++; - setState(() => _heartBursts.add((id: id, position: position))); + _showHeartBurst(globalPosition); if (kDebugMode) { debugPrint( '[player] phase=double-tap-like episodeId=${widget.episodeId}', ); } - widget.onDoubleTapLike?.call(position); + widget.onDoubleTapLike?.call(globalPosition); } - void _removeHeartBurst(int id) { - if (!mounted) return; - setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + void _showHeartBurst(Offset globalPosition) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return; + final overlayBox = overlay.context.findRenderObject(); + final origin = overlayBox is RenderBox + ? overlayBox.globalToLocal(globalPosition) + : globalPosition; + final id = _nextHeartBurstId++; + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => Positioned( + left: origin.dx, + top: origin.dy, + child: IgnorePointer( + child: PlayerDoubleTapHeartBurst( + key: ValueKey('heart-overlay-$id'), + burstId: id, + origin: origin, + onCompleted: () => _removeHeartOverlay(id), + ), + ), + ), + ); + _heartOverlayEntries[id] = entry; + overlay.insert(entry); + } + + void _removeHeartOverlay(int id) { + final entry = _heartOverlayEntries.remove(id); + if (entry == null) return; + entry + ..remove() + ..dispose(); + if (kDebugMode) { + debugPrint('[player] phase=heart-burst-entry-removed id=$id'); + } } void _handleLongPressStart(LongPressStartDetails details, double width) { diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 9463851..65c37c9 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -143,6 +143,75 @@ void main() { expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); + + testWidgets('heart overlay starts at global tap position and is cleaned up', ( + tester, + ) async { + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + key, + clock, + ); + const tapPosition = Offset(120, 220); + + await tester.tapAt(tapPosition); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(tapPosition); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + final firstHeart = find.byKey(const Key('double-tap-heart-0')); + expect(firstHeart, findsOneWidget); + expect( + (tester.getCenter(firstHeart) - tapPosition).distance, + lessThanOrEqualTo(1), + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); + }); + + testWidgets('multiple heart overlays run independently', (tester) async { + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + key, + clock, + ); + + await tester.tapAt(const Offset(100, 180)); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(const Offset(100, 180)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + clock.advance(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tapAt(const Offset(260, 360)); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(const Offset(260, 360)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.byType(PlayerDoubleTapHeartBurst), findsNWidgets(2)); + expect(find.byKey(const ValueKey('heart-overlay-0')), findsOneWidget); + expect(find.byKey(const ValueKey('heart-overlay-1')), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); + }); } Future _pumpHarness( From 9039b27c7f395509b06aa253fbd585eb39f414c8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:31:10 +0800 Subject: [PATCH 33/42] =?UTF-8?q?fix(player):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=A0=B9Overlay=E6=B8=B2=E6=9F=93=E9=A3=98=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 97 ++++++------------- .../widgets/player_tap_gesture_region.dart | 4 +- .../player/player_double_tap_hearts_test.dart | 43 +++++--- 3 files changed, 63 insertions(+), 81 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index baf482b..71f38e6 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -17,10 +17,6 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { this.origin = Offset.zero, }); - static const int minParticleCount = 4; - static const int maxParticleCount = 5; - static const int minimumBurstDurationMs = 2000; - final int burstId; final VoidCallback onCompleted; final Random? random; @@ -55,43 +51,22 @@ class _PlayerDoubleTapHeartBurstState extends State _lifetime.start(); final random = widget.random ?? Random(); var delay = 0; - _particles = List.generate( - PlayerDoubleTapHeartBurst.minParticleCount + - random.nextInt( - PlayerDoubleTapHeartBurst.maxParticleCount - - PlayerDoubleTapHeartBurst.minParticleCount + - 1, - ), - (index) { - if (index > 0) delay += 80 + random.nextInt(41); - return _HeartParticle( - delay: delay, - duration: 1500 + random.nextInt(301), - travel: 100 + random.nextInt(31), - drift: -30 + random.nextInt(61), - size: (22 + random.nextInt(13)).toDouble(), - color: random.nextBool() - ? AppColors.doubleTapHeartRed - : AppColors.doubleTapHeartPink, - ); - }, - ); - var total = _particles + _particles = List.generate(6 + random.nextInt(3), (index) { + if (index > 0) delay += 40 + random.nextInt(21); + return _HeartParticle( + delay: delay, + duration: 700 + random.nextInt(201), + travel: 120 + random.nextInt(41), + drift: -30 + random.nextInt(61), + size: (22 + random.nextInt(13)).toDouble(), + color: random.nextBool() + ? AppColors.doubleTapHeartRed + : AppColors.doubleTapHeartPink, + ); + }); + final total = _particles .map((particle) => particle.delay + particle.duration) .reduce(max); - if (total < PlayerDoubleTapHeartBurst.minimumBurstDurationMs) { - final last = _particles.last; - final requiredDuration = - PlayerDoubleTapHeartBurst.minimumBurstDurationMs - last.delay; - final adjustedDuration = max( - last.duration, - requiredDuration, - ).clamp(1500, 1800).toInt(); - _particles[_particles.length - 1] = last.withDuration(adjustedDuration); - total = _particles - .map((particle) => particle.delay + particle.duration) - .reduce(max); - } if (kDebugMode) { debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); debugPrint( @@ -143,21 +118,20 @@ class _PlayerDoubleTapHeartBurstState extends State @override Widget build(BuildContext context) { return IgnorePointer( - child: SizedBox.shrink( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final elapsed = - _controller.value * _controller.duration!.inMilliseconds; - return Stack( - clipBehavior: Clip.none, - children: [ - for (var index = 0; index < _particles.length; index++) - _particle(_particles[index], elapsed, index), - ], - ); - }, - ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = + _controller.value * _controller.duration!.inMilliseconds; + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _particles.length; index++) + _particle(_particles[index], elapsed, index), + ], + ); + }, ), ); } @@ -171,8 +145,8 @@ class _PlayerDoubleTapHeartBurstState extends State final move = Curves.easeOutCubic.transform(progress); final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); return Positioned( - left: particle.drift * move - particle.size / 2, - top: -particle.size / 2 - particle.travel * move, + left: widget.origin.dx + particle.drift * move - particle.size / 2, + top: widget.origin.dy - particle.size / 2 - particle.travel * move, child: Opacity( opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), child: Transform.scale( @@ -205,15 +179,4 @@ class _HeartParticle { final int drift; final double size; final Color color; - - _HeartParticle withDuration(int nextDuration) { - return _HeartParticle( - delay: delay, - duration: nextDuration, - travel: travel, - drift: drift, - size: size, - color: color, - ); - } } diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 8d36c2b..7d01fe6 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -170,9 +170,7 @@ class _PlayerTapGestureRegionState extends State { final id = _nextHeartBurstId++; late final OverlayEntry entry; entry = OverlayEntry( - builder: (context) => Positioned( - left: origin.dx, - top: origin.dy, + builder: (context) => Positioned.fill( child: IgnorePointer( child: PlayerDoubleTapHeartBurst( key: ValueKey('heart-overlay-$id'), diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 98d7cc4..b7640b5 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -10,25 +10,32 @@ void main() { testWidgets('should_show_randomized_heart_particles_when_burst_starts', ( tester, ) async { + final random = _RecordingRandom(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: PlayerDoubleTapHeartBurst( burstId: 1, onCompleted: _noop, - random: Random(42), + random: random, ), ), ), ); - await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(const Duration(milliseconds: 500)); final hearts = find.byIcon(Icons.favorite_rounded); final count = hearts.evaluate().length; - expect(count, inInclusiveRange(4, 5)); + expect(count, inInclusiveRange(6, 8)); for (var index = 0; index < count; index++) { expect(_size(tester, index), inInclusiveRange(22, 34)); } + expect(random.intBounds.first, 3); + expect(random.intBounds.where((bound) => bound == 21), hasLength(7)); + expect(random.intBounds.where((bound) => bound == 201), hasLength(8)); + expect(random.intBounds.where((bound) => bound == 41), hasLength(8)); + expect(random.intBounds.where((bound) => bound == 61), hasLength(8)); + expect(random.intBounds.where((bound) => bound == 13), hasLength(8)); expect( tester .widgetList( @@ -50,11 +57,9 @@ void main() { closeTo(1.15, 1e-9), ); expect(PlayerDoubleTapHeartBurst.scaleForProgress(1), closeTo(0.2, 1e-9)); - expect(PlayerDoubleTapHeartBurst.minParticleCount, 4); - expect(PlayerDoubleTapHeartBurst.maxParticleCount, 5); }); - testWidgets('heart burst completes after at least two simulated seconds', ( + testWidgets('heart burst completes around 1.2 to 1.4 seconds', ( tester, ) async { var completed = false; @@ -64,23 +69,23 @@ void main() { home: Scaffold( body: PlayerDoubleTapHeartBurst( burstId: 2, - random: Random(7), + random: _RecordingRandom(), onCompleted: () => completed = true, ), ), ), ); - while (!completed && elapsedMs < 4000) { + while (!completed && elapsedMs < 2000) { await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(2000)); - expect(elapsedMs, lessThanOrEqualTo(2600)); + expect(elapsedMs, greaterThanOrEqualTo(1200)); + expect(elapsedMs, lessThanOrEqualTo(1400)); final count = find.byIcon(Icons.favorite_rounded).evaluate().length; - expect(count, inInclusiveRange(4, 5)); + expect(count, inInclusiveRange(6, 8)); }); } @@ -89,3 +94,19 @@ double _size(WidgetTester tester, int index) { } void _noop() {} + +class _RecordingRandom implements Random { + final List intBounds = []; + + @override + bool nextBool() => true; + + @override + double nextDouble() => 0.5; + + @override + int nextInt(int max) { + intBounds.add(max); + return max - 1; + } +} From 84b24775d6600477c84eacae11bf6cd830f853c3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:38:59 +0800 Subject: [PATCH 34/42] =?UTF-8?q?fix(player):=20=E6=81=A2=E5=A4=8Ddevelop?= =?UTF-8?q?=E9=A3=98=E5=BF=83=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/home_feed_overlay.dart | 1 - .../widgets/player_double_tap_hearts.dart | 162 +++++++----------- lib/pages/player/widgets/player_overlay.dart | 1 - .../widgets/player_tap_gesture_region.dart | 79 +++------ test/player/double_tap_like_test.dart | 14 +- .../player/player_double_tap_hearts_test.dart | 75 ++------ 6 files changed, 107 insertions(+), 225 deletions(-) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 29232b9..15411ee 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -132,7 +132,6 @@ class _HomeFeedOverlayState extends State { child: PlayerTapGestureRegion( key: const ValueKey('home-feed-tap-gesture-region'), gestureKey: const Key('home-feed-gesture'), - episodeId: widget.episode.id, enabled: !_scrubbing, doubleTapEnabled: !widget.locked, now: widget.now, diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 71f38e6..568c86e 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,37 +1,43 @@ // Author: fruit.com.cn -import 'dart:math'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +const int _heartDurationMs = 680; +const double _heartPopPortion = 0.18; +const double _heartStartScale = 0.7; +const double _heartHoldPortion = 0.45; + +const List<_HeartParticle> _hearts = [ + _HeartParticle( + delay: 0, + travel: 108, + drift: 0, + size: 48, + color: AppColors.doubleTapHeartRed, + ), + _HeartParticle( + delay: 50, + travel: 96, + drift: -28, + size: 36, + color: AppColors.doubleTapHeartPink, + ), + _HeartParticle( + delay: 90, + travel: 118, + drift: 26, + size: 40, + color: AppColors.doubleTapHeartRed, + ), +]; + /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { - const PlayerDoubleTapHeartBurst({ - super.key, - required this.burstId, - required this.onCompleted, - this.random, - this.origin = Offset.zero, - }); + const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); - final int burstId; final VoidCallback onCompleted; - final Random? random; - final Offset origin; - - @visibleForTesting - static double opacityForProgress(double progress) => - 1 - Curves.easeIn.transform(progress); - - @visibleForTesting - static double scaleForProgress(double progress) { - return progress < 0.3 - ? 0.55 + (1.15 - 0.55) * (progress / 0.3) - : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); - } @override State createState() => @@ -41,41 +47,14 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final List<_HeartParticle> _particles; - final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); - _lifetime.start(); - final random = widget.random ?? Random(); - var delay = 0; - _particles = List.generate(6 + random.nextInt(3), (index) { - if (index > 0) delay += 40 + random.nextInt(21); - return _HeartParticle( - delay: delay, - duration: 700 + random.nextInt(201), - travel: 120 + random.nextInt(41), - drift: -30 + random.nextInt(61), - size: (22 + random.nextInt(13)).toDouble(), - color: random.nextBool() - ? AppColors.doubleTapHeartRed - : AppColors.doubleTapHeartPink, - ); - }); - final total = _particles - .map((particle) => particle.delay + particle.duration) - .reduce(max); - if (kDebugMode) { - debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); - debugPrint( - '[player] phase=heart-burst-start ' - 'id=${widget.burstId} count=${_particles.length} ' - 'at=${widget.origin.dx.toStringAsFixed(1)},' - '${widget.origin.dy.toStringAsFixed(1)}', - ); - } + final total = _hearts + .map((heart) => heart.delay + _heartDurationMs) + .reduce((left, right) => left > right ? left : right); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), @@ -86,29 +65,11 @@ class _PlayerDoubleTapHeartBurstState extends State void _onStatus(AnimationStatus status) { if (status != AnimationStatus.completed || _completed) return; _completed = true; - if (kDebugMode) { - final elapsedMs = _controller.duration?.inMilliseconds ?? 0; - debugPrint( - '[player] phase=heart-burst-complete ' - 'id=${widget.burstId} configuredMs=$elapsedMs', - ); - } widget.onCompleted(); } @override void dispose() { - if (kDebugMode) { - final elapsedMs = _completed - ? _controller.duration?.inMilliseconds ?? 0 - : _lifetime.elapsedMilliseconds; - debugPrint( - '[player] phase=heart-burst-dispose ' - 'id=${widget.burstId} elapsedMs=$elapsedMs ' - 'completed=${_completed ? 1 : 0}', - ); - } - _lifetime.stop(); _controller ..removeStatusListener(_onStatus) ..dispose(); @@ -118,44 +79,51 @@ class _PlayerDoubleTapHeartBurstState extends State @override Widget build(BuildContext context) { return IgnorePointer( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final elapsed = - _controller.value * _controller.duration!.inMilliseconds; - return Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - for (var index = 0; index < _particles.length; index++) - _particle(_particles[index], elapsed, index), - ], - ); - }, + child: SizedBox.shrink( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = + _controller.value * _controller.duration!.inMilliseconds; + return Stack( + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _hearts.length; index++) + _heart(_hearts[index], elapsed, index), + ], + ); + }, + ), ), ); } - Widget _particle(_HeartParticle particle, double elapsed, int index) { - final progress = ((elapsed - particle.delay) / particle.duration).clamp( + Widget _heart(_HeartParticle heart, double elapsed, int index) { + final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); final move = Curves.easeOutCubic.transform(progress); - final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); + final scale = progress < _heartPopPortion + ? _heartStartScale + + (1 - _heartStartScale) * (progress / _heartPopPortion) + : 1.0; + final opacity = progress < _heartHoldPortion + ? 1.0 + : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); return Positioned( - left: widget.origin.dx + particle.drift * move - particle.size / 2, - top: widget.origin.dy - particle.size / 2 - particle.travel * move, + left: heart.drift * move - heart.size / 2, + top: -heart.size / 2 - heart.travel * move, child: Opacity( - opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), + opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( scale: scale, child: Icon( key: Key('double-tap-heart-$index'), Icons.favorite_rounded, - color: particle.color, - size: particle.size, + color: heart.color, + size: heart.size, ), ), ), @@ -166,7 +134,6 @@ class _PlayerDoubleTapHeartBurstState extends State class _HeartParticle { const _HeartParticle({ required this.delay, - required this.duration, required this.travel, required this.drift, required this.size, @@ -174,9 +141,8 @@ class _HeartParticle { }); final int delay; - final int duration; - final int travel; - final int drift; + final double travel; + final double drift; final double size; final Color color; } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 8d3f5f1..11ff157 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -61,7 +61,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: PlayerTapGestureRegion( key: const ValueKey('player-tap-gesture-region'), gestureKey: const Key('player-video-tap'), - episodeId: data.episode.id, enabled: !scrubbing, doubleTapEnabled: !data.locked, onSingleTap: actions.onTogglePlay, diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 7d01fe6..f3b06a6 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -2,7 +2,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'player_double_tap_hearts.dart'; @@ -15,7 +14,6 @@ class PlayerTapGestureRegion extends StatefulWidget { const PlayerTapGestureRegion({ super.key, required this.gestureKey, - required this.episodeId, required this.onSingleTap, required this.onLongPressStart, required this.onInteractionEnd, @@ -26,7 +24,6 @@ class PlayerTapGestureRegion extends StatefulWidget { }); final Key gestureKey; - final String episodeId; final VoidCallback onSingleTap; final ValueChanged? onDoubleTapLike; final PlayerLongPressStartCallback onLongPressStart; @@ -50,18 +47,12 @@ class _PlayerTapGestureRegionState extends State { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final Map _heartOverlayEntries = {}; + final List<({int id, Offset position})> _heartBursts = []; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); - for (final entry in _heartOverlayEntries.values.toList()) { - entry - ..remove() - ..dispose(); - } - _heartOverlayEntries.clear(); super.dispose(); } @@ -87,6 +78,25 @@ class _PlayerTapGestureRegionState extends State { onLongPressEnd: (_) => widget.onInteractionEnd(), onLongPressCancel: widget.onInteractionEnd, ), + if (_heartBursts.isNotEmpty) + Positioned.fill( + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final burst in _heartBursts) + Positioned( + left: burst.position.dx, + top: burst.position.dy - 10, + child: PlayerDoubleTapHeartBurst( + key: ValueKey(burst.id), + onCompleted: () => _removeHeartBurst(burst.id), + ), + ), + ], + ), + ), + ), ], ), ), @@ -128,7 +138,7 @@ class _PlayerTapGestureRegionState extends State { void _handleTapUp(TapUpDetails details) { final now = _now(); if (_doubleTapDown || _singleTapBlocked(now)) { - _handleDoubleTapLike(details.globalPosition); + _handleDoubleTapLike(details.localPosition); return; } final serial = _downSerial; @@ -146,54 +156,19 @@ class _PlayerTapGestureRegionState extends State { _singleTapTimer = pending; } - void _handleDoubleTapLike(Offset globalPosition) { + void _handleDoubleTapLike(Offset position) { _blockSingleTapUntil = _now().add(_singleTapDelay); _cancelPendingSingleTap(); if (!widget.doubleTapEnabled || _likedThisPair) return; _likedThisPair = true; - _showHeartBurst(globalPosition); - if (kDebugMode) { - debugPrint( - '[player] phase=double-tap-like episodeId=${widget.episodeId}', - ); - } - widget.onDoubleTapLike?.call(globalPosition); - } - - void _showHeartBurst(Offset globalPosition) { - final overlay = Overlay.maybeOf(context, rootOverlay: true); - if (overlay == null) return; - final overlayBox = overlay.context.findRenderObject(); - final origin = overlayBox is RenderBox - ? overlayBox.globalToLocal(globalPosition) - : globalPosition; final id = _nextHeartBurstId++; - late final OverlayEntry entry; - entry = OverlayEntry( - builder: (context) => Positioned.fill( - child: IgnorePointer( - child: PlayerDoubleTapHeartBurst( - key: ValueKey('heart-overlay-$id'), - burstId: id, - origin: origin, - onCompleted: () => _removeHeartOverlay(id), - ), - ), - ), - ); - _heartOverlayEntries[id] = entry; - overlay.insert(entry); + setState(() => _heartBursts.add((id: id, position: position))); + widget.onDoubleTapLike?.call(position); } - void _removeHeartOverlay(int id) { - final entry = _heartOverlayEntries.remove(id); - if (entry == null) return; - entry - ..remove() - ..dispose(); - if (kDebugMode) { - debugPrint('[player] phase=heart-burst-entry-removed id=$id'); - } + void _removeHeartBurst(int id) { + if (!mounted) return; + setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); } void _handleLongPressStart(LongPressStartDetails details, double width) { diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 65c37c9..f0b63bc 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -144,7 +144,7 @@ void main() { expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); - testWidgets('heart overlay starts at global tap position and is cleaned up', ( + testWidgets('heart burst is cleaned up when gesture region is disposed', ( tester, ) async { final key = GlobalKey<_HarnessState>(); @@ -165,19 +165,14 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); - final firstHeart = find.byKey(const Key('double-tap-heart-0')); - expect(firstHeart, findsOneWidget); - expect( - (tester.getCenter(firstHeart) - tapPosition).distance, - lessThanOrEqualTo(1), - ); + expect(find.byType(PlayerDoubleTapHeartBurst), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); }); - testWidgets('multiple heart overlays run independently', (tester) async { + testWidgets('multiple heart bursts run independently', (tester) async { final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness( @@ -205,9 +200,6 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsNWidgets(2)); - expect(find.byKey(const ValueKey('heart-overlay-0')), findsOneWidget); - expect(find.byKey(const ValueKey('heart-overlay-1')), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index b7640b5..4c3a933 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -1,41 +1,24 @@ // Author: fruit.com.cn -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; void main() { - testWidgets('should_show_randomized_heart_particles_when_burst_starts', ( + testWidgets('should_show_three_develop_hearts_when_burst_starts', ( tester, ) async { - final random = _RecordingRandom(); await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: PlayerDoubleTapHeartBurst( - burstId: 1, - onCompleted: _noop, - random: random, - ), - ), + const MaterialApp( + home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), ), ); - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); - final hearts = find.byIcon(Icons.favorite_rounded); - final count = hearts.evaluate().length; - expect(count, inInclusiveRange(6, 8)); - for (var index = 0; index < count; index++) { - expect(_size(tester, index), inInclusiveRange(22, 34)); - } - expect(random.intBounds.first, 3); - expect(random.intBounds.where((bound) => bound == 21), hasLength(7)); - expect(random.intBounds.where((bound) => bound == 201), hasLength(8)); - expect(random.intBounds.where((bound) => bound == 41), hasLength(8)); - expect(random.intBounds.where((bound) => bound == 61), hasLength(8)); - expect(random.intBounds.where((bound) => bound == 13), hasLength(8)); + expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); + expect(_size(tester, 0), 48); + expect(_size(tester, 1), 36); + expect(_size(tester, 2), 40); expect( tester .widgetList( @@ -49,17 +32,7 @@ void main() { ); }); - test('heart particles fade from the start and scale in two phases', () { - expect(PlayerDoubleTapHeartBurst.opacityForProgress(0.5), lessThan(0.7)); - expect(PlayerDoubleTapHeartBurst.scaleForProgress(0), 0.55); - expect( - PlayerDoubleTapHeartBurst.scaleForProgress(0.3), - closeTo(1.15, 1e-9), - ); - expect(PlayerDoubleTapHeartBurst.scaleForProgress(1), closeTo(0.2, 1e-9)); - }); - - testWidgets('heart burst completes around 1.2 to 1.4 seconds', ( + testWidgets('develop heart burst completes after about 770ms', ( tester, ) async { var completed = false; @@ -67,25 +40,19 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst( - burstId: 2, - random: _RecordingRandom(), - onCompleted: () => completed = true, - ), + body: PlayerDoubleTapHeartBurst(onCompleted: () => completed = true), ), ), ); - while (!completed && elapsedMs < 2000) { + while (!completed && elapsedMs < 1200) { await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(1200)); - expect(elapsedMs, lessThanOrEqualTo(1400)); - final count = find.byIcon(Icons.favorite_rounded).evaluate().length; - expect(count, inInclusiveRange(6, 8)); + expect(elapsedMs, greaterThanOrEqualTo(752)); + expect(elapsedMs, lessThanOrEqualTo(800)); }); } @@ -94,19 +61,3 @@ double _size(WidgetTester tester, int index) { } void _noop() {} - -class _RecordingRandom implements Random { - final List intBounds = []; - - @override - bool nextBool() => true; - - @override - double nextDouble() => 0.5; - - @override - int nextInt(int max) { - intBounds.add(max); - return max - 1; - } -} From 810ca5bf9da8a48489986bca1fc357cf53d182ec Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:42:41 +0800 Subject: [PATCH 35/42] =?UTF-8?q?fix(player):=20=E5=BB=B6=E9=95=BF?= =?UTF-8?q?=E9=A3=98=E5=BF=83=E5=B9=B6=E6=98=BE=E7=A4=BA=E6=AF=AB=E7=A7=92?= =?UTF-8?q?=E8=AE=A1=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 68 +++++++++++++++-- .../widgets/player_tap_gesture_region.dart | 73 ++++++++++++------- test/player/double_tap_like_test.dart | 25 ++++++- .../player/player_double_tap_hearts_test.dart | 39 +++++++++- 4 files changed, 165 insertions(+), 40 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 568c86e..8f46837 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,13 +1,14 @@ // Author: fruit.com.cn +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const int _heartDurationMs = 680; +const int _heartDurationMs = 1500; const double _heartPopPortion = 0.18; const double _heartStartScale = 0.7; -const double _heartHoldPortion = 0.45; +const double _heartHoldPortion = 0.55; const List<_HeartParticle> _hearts = [ _HeartParticle( @@ -35,9 +36,14 @@ const List<_HeartParticle> _hearts = [ /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { - const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + const PlayerDoubleTapHeartBurst({ + super.key, + required this.onCompleted, + this.origin = Offset.zero, + }); final VoidCallback onCompleted; + final Offset origin; @override State createState() => @@ -47,14 +53,24 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); + _lifetime.start(); final total = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-start ' + 'id=${identityHashCode(this)} count=${_hearts.length} ' + 'configuredMs=$total at=${widget.origin.dx.toStringAsFixed(1)},' + '${widget.origin.dy.toStringAsFixed(1)}', + ); + } _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), @@ -65,11 +81,37 @@ class _PlayerDoubleTapHeartBurstState extends State void _onStatus(AnimationStatus status) { if (status != AnimationStatus.completed || _completed) return; _completed = true; + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-complete ' + 'id=${identityHashCode(this)} ' + 'configuredMs=${_controller.duration?.inMilliseconds ?? 0}', + ); + } widget.onCompleted(); } @override void dispose() { + if (kDebugMode) { + final elapsedMs = _completed + ? _controller.duration?.inMilliseconds ?? 0 + : _lifetime.elapsedMilliseconds; + debugPrint( + '[player] phase=heart-burst-dispose ' + 'id=${identityHashCode(this)} elapsedMs=$elapsedMs ' + 'completed=${_completed ? 1 : 0}', + ); + if (!_completed) { + debugPrintStack( + maxFrames: 30, + label: + '[player] heart-burst-premature-dispose ' + 'id=${identityHashCode(this)} mount=tap-gesture-region', + ); + } + } + _lifetime.stop(); _controller ..removeStatusListener(_onStatus) ..dispose(); @@ -86,10 +128,26 @@ class _PlayerDoubleTapHeartBurstState extends State final elapsed = _controller.value * _controller.duration!.inMilliseconds; return Stack( + fit: StackFit.expand, clipBehavior: Clip.none, children: [ for (var index = 0; index < _hearts.length; index++) _heart(_hearts[index], elapsed, index), + if (kDebugMode) + Positioned( + left: widget.origin.dx + 8, + top: widget.origin.dy, + child: IgnorePointer( + child: Text( + '${elapsed.round()}ms', + key: const Key('heart-burst-elapsed-debug'), + style: const TextStyle( + color: Colors.white60, + fontSize: 11, + ), + ), + ), + ), ], ); }, @@ -113,8 +171,8 @@ class _PlayerDoubleTapHeartBurstState extends State ? 1.0 : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); return Positioned( - left: heart.drift * move - heart.size / 2, - top: -heart.size / 2 - heart.travel * move, + left: widget.origin.dx + heart.drift * move - heart.size / 2, + top: widget.origin.dy - heart.size / 2 - heart.travel * move, child: Opacity( opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index f3b06a6..d323bb0 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'player_double_tap_hearts.dart'; @@ -47,12 +48,18 @@ class _PlayerTapGestureRegionState extends State { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final List<({int id, Offset position})> _heartBursts = []; + final Map _heartOverlayEntries = {}; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); + for (final entry in _heartOverlayEntries.values.toList()) { + entry + ..remove() + ..dispose(); + } + _heartOverlayEntries.clear(); super.dispose(); } @@ -78,25 +85,6 @@ class _PlayerTapGestureRegionState extends State { onLongPressEnd: (_) => widget.onInteractionEnd(), onLongPressCancel: widget.onInteractionEnd, ), - if (_heartBursts.isNotEmpty) - Positioned.fill( - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final burst in _heartBursts) - Positioned( - left: burst.position.dx, - top: burst.position.dy - 10, - child: PlayerDoubleTapHeartBurst( - key: ValueKey(burst.id), - onCompleted: () => _removeHeartBurst(burst.id), - ), - ), - ], - ), - ), - ), ], ), ), @@ -138,7 +126,7 @@ class _PlayerTapGestureRegionState extends State { void _handleTapUp(TapUpDetails details) { final now = _now(); if (_doubleTapDown || _singleTapBlocked(now)) { - _handleDoubleTapLike(details.localPosition); + _handleDoubleTapLike(details.globalPosition); return; } final serial = _downSerial; @@ -156,19 +144,48 @@ class _PlayerTapGestureRegionState extends State { _singleTapTimer = pending; } - void _handleDoubleTapLike(Offset position) { + void _handleDoubleTapLike(Offset globalPosition) { _blockSingleTapUntil = _now().add(_singleTapDelay); _cancelPendingSingleTap(); if (!widget.doubleTapEnabled || _likedThisPair) return; _likedThisPair = true; - final id = _nextHeartBurstId++; - setState(() => _heartBursts.add((id: id, position: position))); - widget.onDoubleTapLike?.call(position); + _showHeartBurst(globalPosition); + widget.onDoubleTapLike?.call(globalPosition); } - void _removeHeartBurst(int id) { - if (!mounted) return; - setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + void _showHeartBurst(Offset globalPosition) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return; + final overlayBox = overlay.context.findRenderObject(); + final origin = overlayBox is RenderBox + ? overlayBox.globalToLocal(globalPosition) + : globalPosition; + final id = _nextHeartBurstId++; + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => Positioned.fill( + child: IgnorePointer( + child: PlayerDoubleTapHeartBurst( + key: ValueKey('heart-overlay-$id'), + origin: origin, + onCompleted: () => _removeHeartOverlay(id), + ), + ), + ), + ); + _heartOverlayEntries[id] = entry; + overlay.insert(entry); + } + + void _removeHeartOverlay(int id) { + final entry = _heartOverlayEntries.remove(id); + if (entry == null) return; + entry + ..remove() + ..dispose(); + if (kDebugMode) { + debugPrint('[player] phase=heart-burst-entry-removed id=$id'); + } } void _handleLongPressStart(LongPressStartDetails details, double width) { diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index f0b63bc..861c988 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -110,7 +110,7 @@ void main() { expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); - testWidgets('parent rebuild keeps the heart burst state alive', ( + testWidgets('parent rebuild 30 times keeps the heart burst alive', ( tester, ) async { final key = GlobalKey<_HarnessState>(); @@ -135,13 +135,24 @@ void main() { final burstFinder = find.byType(PlayerDoubleTapHeartBurst); final gestureState = tester.state(gestureFinder); final burstState = tester.state(burstFinder); + var elapsedMs = 100; - key.currentState!.rebuild(); - await tester.pump(const Duration(milliseconds: 100)); + for (var index = 0; index < 30; index++) { + key.currentState!.rebuild(); + await tester.pump(const Duration(milliseconds: 16)); + elapsedMs += 16; + } expect(tester.state(gestureFinder), same(gestureState)); expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + + while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { + await tester.pump(const Duration(milliseconds: 16)); + elapsedMs += 16; + } + expect(burstFinder, findsNothing); + expect(elapsedMs, greaterThanOrEqualTo(1500)); }); testWidgets('heart burst is cleaned up when gesture region is disposed', ( @@ -166,6 +177,12 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsOneWidget); + expect( + (tester.getCenter(find.byKey(const Key('double-tap-heart-0'))) - + tapPosition) + .distance, + lessThanOrEqualTo(1), + ); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); @@ -200,6 +217,8 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsNWidgets(2)); + expect(find.byKey(const ValueKey('heart-overlay-0')), findsOneWidget); + expect(find.byKey(const ValueKey('heart-overlay-1')), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 4c3a933..bdae85b 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -19,6 +19,11 @@ void main() { expect(_size(tester, 0), 48); expect(_size(tester, 1), 36); expect(_size(tester, 2), 40); + final elapsedText = tester + .widget(find.byKey(const Key('heart-burst-elapsed-debug'))) + .data!; + final elapsedMs = int.parse(elapsedText.replaceFirst('ms', '')); + expect(elapsedMs, inInclusiveRange(180, 220)); expect( tester .widgetList( @@ -32,7 +37,23 @@ void main() { ); }); - testWidgets('develop heart burst completes after about 770ms', ( + testWidgets('heart burst keeps opacity then fades after 55 percent', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + ), + ); + + await tester.pump(const Duration(milliseconds: 450)); + expect(_opacity(tester, 0), closeTo(1, 1e-9)); + + await tester.pump(const Duration(milliseconds: 750)); + expect(_opacity(tester, 0), lessThan(0.5)); + }); + + testWidgets('develop heart burst completes after about 1590ms', ( tester, ) async { var completed = false; @@ -45,14 +66,14 @@ void main() { ), ); - while (!completed && elapsedMs < 1200) { + while (!completed && elapsedMs < 2000) { await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(752)); - expect(elapsedMs, lessThanOrEqualTo(800)); + expect(elapsedMs, greaterThanOrEqualTo(1584)); + expect(elapsedMs, lessThanOrEqualTo(1616)); }); } @@ -60,4 +81,14 @@ double _size(WidgetTester tester, int index) { return tester.widget(find.byKey(Key('double-tap-heart-$index'))).size!; } +double _opacity(WidgetTester tester, int index) { + final opacity = find + .ancestor( + of: find.byKey(Key('double-tap-heart-$index')), + matching: find.byType(Opacity), + ) + .first; + return tester.widget(opacity).opacity; +} + void _noop() {} From db3e153409015c6517695878116147f8d8f6d3ef Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:49:36 +0800 Subject: [PATCH 36/42] =?UTF-8?q?fix(player):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=A0=B9Overlay=E6=89=BF=E8=BD=BD=E9=A3=98=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/player_double_tap_hearts.dart | 10 ++++++---- .../player/widgets/player_tap_gesture_region.dart | 1 + test/player/player_double_tap_hearts_test.dart | 13 ++++++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 8f46837..3146699 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -38,10 +38,12 @@ const List<_HeartParticle> _hearts = [ class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({ super.key, + required this.burstId, required this.onCompleted, this.origin = Offset.zero, }); + final int burstId; final VoidCallback onCompleted; final Offset origin; @@ -66,7 +68,7 @@ class _PlayerDoubleTapHeartBurstState extends State if (kDebugMode) { debugPrint( '[player] phase=heart-burst-start ' - 'id=${identityHashCode(this)} count=${_hearts.length} ' + 'id=${widget.burstId} count=${_hearts.length} ' 'configuredMs=$total at=${widget.origin.dx.toStringAsFixed(1)},' '${widget.origin.dy.toStringAsFixed(1)}', ); @@ -84,7 +86,7 @@ class _PlayerDoubleTapHeartBurstState extends State if (kDebugMode) { debugPrint( '[player] phase=heart-burst-complete ' - 'id=${identityHashCode(this)} ' + 'id=${widget.burstId} ' 'configuredMs=${_controller.duration?.inMilliseconds ?? 0}', ); } @@ -99,7 +101,7 @@ class _PlayerDoubleTapHeartBurstState extends State : _lifetime.elapsedMilliseconds; debugPrint( '[player] phase=heart-burst-dispose ' - 'id=${identityHashCode(this)} elapsedMs=$elapsedMs ' + 'id=${widget.burstId} elapsedMs=$elapsedMs ' 'completed=${_completed ? 1 : 0}', ); if (!_completed) { @@ -107,7 +109,7 @@ class _PlayerDoubleTapHeartBurstState extends State maxFrames: 30, label: '[player] heart-burst-premature-dispose ' - 'id=${identityHashCode(this)} mount=tap-gesture-region', + 'id=${widget.burstId} mount=root-overlay', ); } } diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index d323bb0..a001021 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -167,6 +167,7 @@ class _PlayerTapGestureRegionState extends State { child: IgnorePointer( child: PlayerDoubleTapHeartBurst( key: ValueKey('heart-overlay-$id'), + burstId: id, origin: origin, onCompleted: () => _removeHeartOverlay(id), ), diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index bdae85b..8ae4781 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -10,7 +10,9 @@ void main() { ) async { await tester.pumpWidget( const MaterialApp( - home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + home: Scaffold( + body: PlayerDoubleTapHeartBurst(burstId: 1, onCompleted: _noop), + ), ), ); await tester.pump(const Duration(milliseconds: 200)); @@ -42,7 +44,9 @@ void main() { ) async { await tester.pumpWidget( const MaterialApp( - home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + home: Scaffold( + body: PlayerDoubleTapHeartBurst(burstId: 2, onCompleted: _noop), + ), ), ); @@ -61,7 +65,10 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst(onCompleted: () => completed = true), + body: PlayerDoubleTapHeartBurst( + burstId: 3, + onCompleted: () => completed = true, + ), ), ), ); From ed2cf4c18c2e4be1b5c7016de1a4d2cc77f7a4cf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:54:05 +0800 Subject: [PATCH 37/42] =?UTF-8?q?fix(player):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=A0=B9Overlay=E6=89=BF=E8=BD=BD=E9=A3=98=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 62 +++++++++---------- .../player/player_double_tap_hearts_test.dart | 42 +++++++++++++ 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 3146699..e0f05a5 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -123,47 +123,43 @@ class _PlayerDoubleTapHeartBurstState extends State @override Widget build(BuildContext context) { return IgnorePointer( - child: SizedBox.shrink( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final elapsed = - _controller.value * _controller.duration!.inMilliseconds; - return Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - for (var index = 0; index < _hearts.length; index++) - _heart(_hearts[index], elapsed, index), - if (kDebugMode) - Positioned( - left: widget.origin.dx + 8, - top: widget.origin.dy, - child: IgnorePointer( - child: Text( - '${elapsed.round()}ms', - key: const Key('heart-burst-elapsed-debug'), - style: const TextStyle( - color: Colors.white60, - fontSize: 11, - ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = + _controller.value * _controller.duration!.inMilliseconds; + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _hearts.length; index++) + _heart(_hearts[index], elapsed, index), + if (kDebugMode) + Positioned( + left: widget.origin.dx + 8, + top: widget.origin.dy + 10, + child: IgnorePointer( + child: Text( + '${elapsed.round()}ms', + key: const Key('heart-burst-elapsed-debug'), + style: const TextStyle( + color: Colors.white60, + fontSize: 11, ), ), ), - ], - ); - }, - ), + ), + ], + ); + }, ), ); } Widget _heart(_HeartParticle heart, double elapsed, int index) { - final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( - 0.0, - 1.0, - ); - if (progress <= 0) return const SizedBox.shrink(); + final rawProgress = (elapsed - heart.delay) / _heartDurationMs; + if (rawProgress < 0) return const SizedBox.shrink(); + final progress = rawProgress.clamp(0.0, 1.0); final move = Curves.easeOutCubic.transform(progress); final scale = progress < _heartPopPortion ? _heartStartScale + diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 8ae4781..b799123 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -57,6 +57,48 @@ void main() { expect(_opacity(tester, 0), lessThan(0.5)); }); + testWidgets('heart position is anchored to the overlay origin', ( + tester, + ) async { + const origin = Offset(300, 546); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: PlayerDoubleTapHeartBurst( + burstId: 4, + origin: origin, + onCompleted: _noop, + ), + ), + ], + ), + ), + ), + ); + + final firstHeart = find.byKey(const Key('double-tap-heart-0')); + expect( + (tester.getCenter(firstHeart) - origin).distance, + lessThanOrEqualTo(1), + ); + final debugTopLeft = tester.getTopLeft( + find.byKey(const Key('heart-burst-elapsed-debug')), + ); + expect( + (debugTopLeft - const Offset(308, 556)).distance, + lessThanOrEqualTo(1), + ); + + await tester.pump(const Duration(milliseconds: 750)); + final midpoint = tester.getCenter(firstHeart); + expect(midpoint.dy, lessThan(origin.dy)); + expect((midpoint.dx - origin.dx).abs(), lessThanOrEqualTo(1)); + }); + testWidgets('develop heart burst completes after about 1590ms', ( tester, ) async { From a7d01e0306f65698f95061e48b2418c1186adb7e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:57:04 +0800 Subject: [PATCH 38/42] =?UTF-8?q?test(player):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=A3=98=E5=BF=83=E5=83=8F=E7=B4=A0=E5=AE=9A=E4=BD=8D=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/player/player_hearts_pixels_test.dart | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/player/player_hearts_pixels_test.dart diff --git a/test/player/player_hearts_pixels_test.dart b/test/player/player_hearts_pixels_test.dart new file mode 100644 index 0000000..704086b --- /dev/null +++ b/test/player/player_hearts_pixels_test.dart @@ -0,0 +1,66 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; + +void main() { + testWidgets('heart pixels start at origin and move upward', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + var completedCalls = 0; + const origin = Offset(300, 546); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: PlayerDoubleTapHeartBurst( + burstId: 91, + origin: origin, + onCompleted: () => completedCalls++, + ), + ), + ], + ), + ), + ), + ); + + final firstHeart = find.byKey(const Key('double-tap-heart-0')); + expect(firstHeart, findsOneWidget); + final firstSize = tester.getSize(firstHeart); + final initialCenter = tester.getCenter(firstHeart); + debugPrint( + '[heart-pixels] initial size=${firstSize.width}x${firstSize.height} ' + 'center=${initialCenter.dx},${initialCenter.dy}', + ); + expect(firstSize.width, closeTo(48, 1)); + expect(firstSize.height, closeTo(48, 1)); + expect(firstSize.width, greaterThan(20)); + expect((initialCenter - origin).distance, lessThanOrEqualTo(1)); + expect( + (tester.getTopLeft(find.byKey(const Key('heart-burst-elapsed-debug'))) - + const Offset(308, 556)) + .distance, + lessThanOrEqualTo(1), + ); + + await tester.pump(const Duration(milliseconds: 400)); + final midpoint = tester.getCenter(firstHeart); + debugPrint( + '[heart-pixels] after400ms center=${midpoint.dx},${midpoint.dy}', + ); + expect(midpoint.dy, lessThan(origin.dy - 40)); + expect((midpoint.dx - origin.dx).abs(), lessThanOrEqualTo(30)); + + await tester.pump(const Duration(milliseconds: 1200)); + expect(completedCalls, 1); + }); +} From c0f65042e66b909665592ffd471ab7a628cdbabc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 19:00:37 +0800 Subject: [PATCH 39/42] =?UTF-8?q?fix(player):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E9=A3=98=E5=BF=83Overlay=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 12 +- .../widgets/player_heart_overlay_scope.dart | 52 ++++++++ .../widgets/player_tap_gesture_region.dart | 36 +++--- test/player/double_tap_like_test.dart | 112 +++++++++++++++++- 4 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 lib/pages/player/widgets/player_heart_overlay_scope.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 49cca34..9b3a37f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -39,6 +39,7 @@ import 'widgets/exit_clear_screen_button.dart'; import 'widgets/feed_pull_refresh.dart'; import 'widgets/player_center_toggle.dart'; import 'widgets/player_loading_overlay.dart'; +import 'widgets/player_heart_overlay_scope.dart'; import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; @@ -147,6 +148,8 @@ class _PlayerPageState extends State bool _loadingRemoved = true; bool _loadingFadeScheduled = false; bool _loggedLoadingUiVisible = false; + final PlayerHeartOverlayController _heartOverlayController = + PlayerHeartOverlayController(); final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -306,6 +309,7 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); + _heartOverlayController.dispose(); _spriteAxisService ..removeListener(_handleSpriteFrameChanged) ..dispose(); @@ -701,6 +705,10 @@ class _PlayerPageState extends State ), ], ); + final scopedBody = PlayerHeartOverlayScope( + controller: _heartOverlayController, + child: body, + ); final player = AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( @@ -713,12 +721,12 @@ class _PlayerPageState extends State child: Theme( data: AppTheme.dark(), child: widget.embedded - ? body + ? scopedBody : Scaffold( key: const Key('player-scaffold'), backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, - body: body, + body: scopedBody, ), ), ); diff --git a/lib/pages/player/widgets/player_heart_overlay_scope.dart b/lib/pages/player/widgets/player_heart_overlay_scope.dart new file mode 100644 index 0000000..f957ef1 --- /dev/null +++ b/lib/pages/player/widgets/player_heart_overlay_scope.dart @@ -0,0 +1,52 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Tracks heart overlays for the lifetime of one player page. +class PlayerHeartOverlayController { + final Map _entries = {}; + + void track(int id, OverlayEntry entry) { + _entries[id] = entry; + } + + void untrack(int id) { + _entries.remove(id); + } + + void dispose() { + for (final entry in _entries.entries.toList()) { + if (entry.value.mounted) entry.value.remove(); + entry.value.dispose(); + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-entry-removed ' + 'id=${entry.key} reason=page-dispose', + ); + } + } + _entries.clear(); + } +} + +class PlayerHeartOverlayScope extends InheritedWidget { + const PlayerHeartOverlayScope({ + super.key, + required this.controller, + required super.child, + }); + + final PlayerHeartOverlayController controller; + + static PlayerHeartOverlayController? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.controller; + } + + @override + bool updateShouldNotify(PlayerHeartOverlayScope oldWidget) { + return controller != oldWidget.controller; + } +} diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index a001021..e6e10e4 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'player_double_tap_hearts.dart'; +import 'player_heart_overlay_scope.dart'; typedef PlayerLongPressStartCallback = void Function(LongPressStartDetails details, double width); @@ -48,18 +49,11 @@ class _PlayerTapGestureRegionState extends State { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final Map _heartOverlayEntries = {}; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); - for (final entry in _heartOverlayEntries.values.toList()) { - entry - ..remove() - ..dispose(); - } - _heartOverlayEntries.clear(); super.dispose(); } @@ -161,7 +155,20 @@ class _PlayerTapGestureRegionState extends State { ? overlayBox.globalToLocal(globalPosition) : globalPosition; final id = _nextHeartBurstId++; + final controller = PlayerHeartOverlayScope.maybeOf(context); + var removed = false; late final OverlayEntry entry; + void removeEntry() { + if (removed) return; + removed = true; + controller?.untrack(id); + if (entry.mounted) entry.remove(); + entry.dispose(); + if (kDebugMode) { + debugPrint('[player] phase=heart-burst-entry-removed id=$id'); + } + } + entry = OverlayEntry( builder: (context) => Positioned.fill( child: IgnorePointer( @@ -169,26 +176,15 @@ class _PlayerTapGestureRegionState extends State { key: ValueKey('heart-overlay-$id'), burstId: id, origin: origin, - onCompleted: () => _removeHeartOverlay(id), + onCompleted: removeEntry, ), ), ), ); - _heartOverlayEntries[id] = entry; + controller?.track(id, entry); overlay.insert(entry); } - void _removeHeartOverlay(int id) { - final entry = _heartOverlayEntries.remove(id); - if (entry == null) return; - entry - ..remove() - ..dispose(); - if (kDebugMode) { - debugPrint('[player] phase=heart-burst-entry-removed id=$id'); - } - } - void _handleLongPressStart(LongPressStartDetails details, double width) { _tapSeq++; _cancelPendingSingleTap(); diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 861c988..03c6246 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -7,6 +7,7 @@ import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; +import 'package:fruitshorts/pages/player/widgets/player_heart_overlay_scope.dart'; import 'package:fruitshorts/pages/player/widgets/player_tap_gesture_region.dart'; import '../support/fake_catalog.dart'; @@ -155,9 +156,7 @@ void main() { expect(elapsedMs, greaterThanOrEqualTo(1500)); }); - testWidgets('heart burst is cleaned up when gesture region is disposed', ( - tester, - ) async { + testWidgets('heart burst starts at tapped location', (tester) async { final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness( @@ -183,10 +182,62 @@ void main() { .distance, lessThanOrEqualTo(1), ); + }); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); + testWidgets('heart burst survives gesture region disposal', (tester) async { + final logs = []; + final originalDebugPrint = debugPrint; + debugPrint = (message, {wrapWidth}) { + if (message != null) logs.add(message); + }; + var elapsedMs = 0; + try { + final key = GlobalKey<_RemovableGestureHarnessState>(); + await tester.pumpWidget( + MaterialApp(home: _RemovableGestureHarness(key: key)), + ); + const tapPosition = Offset(120, 220); + + await tester.tapAt(tapPosition); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(tapPosition); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final burstFinder = find.byType(PlayerDoubleTapHeartBurst); + expect(burstFinder, findsOneWidget); + key.currentState!.removeGesture(); + await tester.pump(); + expect(find.byKey(const Key('removable-tap-gesture')), findsNothing); + expect(burstFinder, findsOneWidget); + + elapsedMs = 100; + while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { + await tester.pump(const Duration(milliseconds: 16)); + elapsedMs += 16; + } + expect(burstFinder, findsNothing); + } finally { + debugPrint = originalDebugPrint; + } + + expect(elapsedMs, greaterThanOrEqualTo(1500)); + expect( + logs.any( + (line) => + line.contains('phase=heart-burst-complete') && + line.contains('configuredMs=1590'), + ), + isTrue, + ); + expect( + logs.any( + (line) => + line.contains('phase=heart-burst-dispose') && + line.contains('completed=1'), + ), + isTrue, + ); }); testWidgets('multiple heart bursts run independently', (tester) async { @@ -328,6 +379,55 @@ class _HarnessState extends State<_Harness> { } } +class _RemovableGestureHarness extends StatefulWidget { + const _RemovableGestureHarness({super.key}); + + @override + State<_RemovableGestureHarness> createState() => + _RemovableGestureHarnessState(); +} + +class _RemovableGestureHarnessState extends State<_RemovableGestureHarness> { + final PlayerHeartOverlayController _heartController = + PlayerHeartOverlayController(); + bool _showGesture = true; + + void removeGesture() => setState(() => _showGesture = false); + + @override + void dispose() { + _heartController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PlayerHeartOverlayScope( + controller: _heartController, + child: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + if (_showGesture) + Positioned.fill( + child: PlayerTapGestureRegion( + key: const Key('removable-tap-gesture'), + gestureKey: const Key('removable-gesture'), + enabled: true, + doubleTapEnabled: true, + onSingleTap: () {}, + onDoubleTapLike: (_) {}, + onLongPressStart: (_, _) {}, + onInteractionEnd: () {}, + ), + ), + ], + ), + ), + ); + } +} + class _TestClock { DateTime _now = DateTime(2026, 10, 22, 12); From 978c309cd75042f797d9e9bbb49d1a30cb865610 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 19:07:56 +0800 Subject: [PATCH 40/42] =?UTF-8?q?fix(player):=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=A2=99=E9=92=9F=E9=A9=B1=E5=8A=A8=E9=A3=98=E5=BF=83=E5=8A=A8?= =?UTF-8?q?=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 114 ++++++++++-------- .../widgets/player_tap_gesture_region.dart | 1 + test/player/double_tap_like_test.dart | 46 ++++--- .../player/player_double_tap_hearts_test.dart | 70 ++++++++--- test/player/player_fullscreen_test.dart | 18 ++- test/player/player_hearts_pixels_test.dart | 86 ++++++++++--- 6 files changed, 235 insertions(+), 100 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index e0f05a5..1d51a9d 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -41,53 +43,84 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { required this.burstId, required this.onCompleted, this.origin = Offset.zero, + this.now, }); final int burstId; final VoidCallback onCompleted; final Offset origin; + final DateTime Function()? now; @override State createState() => _PlayerDoubleTapHeartBurstState(); } -class _PlayerDoubleTapHeartBurstState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller; +class _PlayerDoubleTapHeartBurstState extends State { + Timer? _timer; + late final DateTime _startAt; + late final int _totalMs; final Stopwatch _lifetime = Stopwatch(); + int _elapsedMs = 0; + int _tickCount = 0; bool _completed = false; @override void initState() { super.initState(); _lifetime.start(); - final total = _hearts + _startAt = _now(); + _totalMs = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); if (kDebugMode) { debugPrint( '[player] phase=heart-burst-start ' 'id=${widget.burstId} count=${_hearts.length} ' - 'configuredMs=$total at=${widget.origin.dx.toStringAsFixed(1)},' + 'configuredMs=$_totalMs at=${widget.origin.dx.toStringAsFixed(1)},' '${widget.origin.dy.toStringAsFixed(1)}', ); } - _controller = AnimationController( - vsync: this, - duration: Duration(milliseconds: total), - )..addStatusListener(_onStatus); - _controller.forward(); + _timer = Timer.periodic( + const Duration(milliseconds: 16), + (_) => _handleTick(), + ); } - void _onStatus(AnimationStatus status) { - if (status != AnimationStatus.completed || _completed) return; + DateTime _now() => widget.now?.call() ?? DateTime.now(); + + void _handleTick() { + if (!mounted || _completed) return; + final rawElapsedMs = _now().difference(_startAt).inMilliseconds; + final elapsedMs = rawElapsedMs < 0 + ? 0 + : rawElapsedMs > _totalMs + ? _totalMs + : rawElapsedMs; + _elapsedMs = elapsedMs; + _tickCount++; + if (kDebugMode && _tickCount % 10 == 0) { + debugPrint( + '[player] phase=heart-tick id=${widget.burstId} ' + 'elapsedMs=$_elapsedMs', + ); + } + if (_elapsedMs >= _totalMs) { + _complete(); + return; + } + setState(() {}); + } + + void _complete() { + if (_completed) return; _completed = true; + _timer?.cancel(); if (kDebugMode) { debugPrint( '[player] phase=heart-burst-complete ' 'id=${widget.burstId} ' - 'configuredMs=${_controller.duration?.inMilliseconds ?? 0}', + 'configuredMs=$_totalMs', ); } widget.onCompleted(); @@ -96,9 +129,7 @@ class _PlayerDoubleTapHeartBurstState extends State @override void dispose() { if (kDebugMode) { - final elapsedMs = _completed - ? _controller.duration?.inMilliseconds ?? 0 - : _lifetime.elapsedMilliseconds; + final elapsedMs = _completed ? _totalMs : _elapsedMs; debugPrint( '[player] phase=heart-burst-dispose ' 'id=${widget.burstId} elapsedMs=$elapsedMs ' @@ -114,44 +145,33 @@ class _PlayerDoubleTapHeartBurstState extends State } } _lifetime.stop(); - _controller - ..removeStatusListener(_onStatus) - ..dispose(); + _timer?.cancel(); + _timer = null; super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final elapsed = - _controller.value * _controller.duration!.inMilliseconds; - return Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - for (var index = 0; index < _hearts.length; index++) - _heart(_hearts[index], elapsed, index), - if (kDebugMode) - Positioned( - left: widget.origin.dx + 8, - top: widget.origin.dy + 10, - child: IgnorePointer( - child: Text( - '${elapsed.round()}ms', - key: const Key('heart-burst-elapsed-debug'), - style: const TextStyle( - color: Colors.white60, - fontSize: 11, - ), - ), - ), + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _hearts.length; index++) + _heart(_hearts[index], _elapsedMs.toDouble(), index), + if (kDebugMode) + Positioned( + left: widget.origin.dx + 8, + top: widget.origin.dy + 10, + child: IgnorePointer( + child: Text( + '${_elapsedMs}ms', + key: const Key('heart-burst-elapsed-debug'), + style: const TextStyle(color: Colors.white60, fontSize: 11), ), - ], - ); - }, + ), + ), + ], ), ); } diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index e6e10e4..032d159 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -176,6 +176,7 @@ class _PlayerTapGestureRegionState extends State { key: ValueKey('heart-overlay-$id'), burstId: id, origin: origin, + now: widget.now, onCompleted: removeEntry, ), ), diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 03c6246..ce253a4 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -48,7 +48,6 @@ void main() { ), findsOneWidget, ); - await tester.pumpAndSettle(); } }); @@ -94,7 +93,7 @@ void main() { await tester.tapAt(center); await tester.pump(); expect(repository.likeTargets, hasLength(1)); - await tester.pump(const Duration(milliseconds: 2300)); + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); @@ -136,24 +135,19 @@ void main() { final burstFinder = find.byType(PlayerDoubleTapHeartBurst); final gestureState = tester.state(gestureFinder); final burstState = tester.state(burstFinder); - var elapsedMs = 100; for (var index = 0; index < 30; index++) { key.currentState!.rebuild(); + clock.advance(const Duration(milliseconds: 16)); await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; } expect(tester.state(gestureFinder), same(gestureState)); expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); - while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { - await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; - } + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); expect(burstFinder, findsNothing); - expect(elapsedMs, greaterThanOrEqualTo(1500)); }); testWidgets('heart burst starts at tapped location', (tester) async { @@ -192,17 +186,21 @@ void main() { }; var elapsedMs = 0; try { + final clock = _TestClock(); final key = GlobalKey<_RemovableGestureHarnessState>(); await tester.pumpWidget( - MaterialApp(home: _RemovableGestureHarness(key: key)), + MaterialApp( + home: _RemovableGestureHarness(key: key, clock: clock), + ), ); const tapPosition = Offset(120, 220); await tester.tapAt(tapPosition); + clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(tapPosition); await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + await _pumpTestClock(tester, clock, const Duration(milliseconds: 100)); final burstFinder = find.byType(PlayerDoubleTapHeartBurst); expect(burstFinder, findsOneWidget); @@ -212,10 +210,8 @@ void main() { expect(burstFinder, findsOneWidget); elapsedMs = 100; - while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { - await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; - } + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); + elapsedMs += 1600; expect(burstFinder, findsNothing); } finally { debugPrint = originalDebugPrint; @@ -380,7 +376,9 @@ class _HarnessState extends State<_Harness> { } class _RemovableGestureHarness extends StatefulWidget { - const _RemovableGestureHarness({super.key}); + const _RemovableGestureHarness({super.key, required this.clock}); + + final _TestClock clock; @override State<_RemovableGestureHarness> createState() => @@ -415,6 +413,7 @@ class _RemovableGestureHarnessState extends State<_RemovableGestureHarness> { gestureKey: const Key('removable-gesture'), enabled: true, doubleTapEnabled: true, + now: widget.clock.now, onSingleTap: () {}, onDoubleTapLike: (_) {}, onLongPressStart: (_, _) {}, @@ -437,3 +436,18 @@ class _TestClock { _now = _now.add(duration); } } + +Future _pumpTestClock( + WidgetTester tester, + _TestClock clock, + Duration duration, +) async { + const frameMs = 16; + var remainingMs = duration.inMilliseconds; + while (remainingMs > 0) { + final stepMs = remainingMs >= frameMs ? frameMs : remainingMs; + clock.advance(Duration(milliseconds: stepMs)); + await tester.pump(const Duration(milliseconds: frameMs)); + remainingMs -= stepMs; + } +} diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index b799123..61776c4 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -8,14 +8,19 @@ void main() { testWidgets('should_show_three_develop_hearts_when_burst_starts', ( tester, ) async { + final clock = _HeartClock(); await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst(burstId: 1, onCompleted: _noop), + body: PlayerDoubleTapHeartBurst( + burstId: 1, + now: clock.now, + onCompleted: _noop, + ), ), ), ); - await tester.pump(const Duration(milliseconds: 200)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 200)); expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); expect(_size(tester, 0), 48); @@ -42,18 +47,23 @@ void main() { testWidgets('heart burst keeps opacity then fades after 55 percent', ( tester, ) async { + final clock = _HeartClock(); await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst(burstId: 2, onCompleted: _noop), + body: PlayerDoubleTapHeartBurst( + burstId: 2, + now: clock.now, + onCompleted: _noop, + ), ), ), ); - await tester.pump(const Duration(milliseconds: 450)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 450)); expect(_opacity(tester, 0), closeTo(1, 1e-9)); - await tester.pump(const Duration(milliseconds: 750)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 750)); expect(_opacity(tester, 0), lessThan(0.5)); }); @@ -61,8 +71,9 @@ void main() { tester, ) async { const origin = Offset(300, 546); + final clock = _HeartClock(); await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( body: Stack( fit: StackFit.expand, @@ -71,6 +82,7 @@ void main() { child: PlayerDoubleTapHeartBurst( burstId: 4, origin: origin, + now: clock.now, onCompleted: _noop, ), ), @@ -93,7 +105,7 @@ void main() { lessThanOrEqualTo(1), ); - await tester.pump(const Duration(milliseconds: 750)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 750)); final midpoint = tester.getCenter(firstHeart); expect(midpoint.dy, lessThan(origin.dy)); expect((midpoint.dx - origin.dx).abs(), lessThanOrEqualTo(1)); @@ -103,26 +115,24 @@ void main() { tester, ) async { var completed = false; - var elapsedMs = 0; + final clock = _HeartClock(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: PlayerDoubleTapHeartBurst( burstId: 3, + now: clock.now, onCompleted: () => completed = true, ), ), ), ); - while (!completed && elapsedMs < 2000) { - await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; - } + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 1600)); expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(1584)); - expect(elapsedMs, lessThanOrEqualTo(1616)); + expect(clock.elapsedMs, greaterThanOrEqualTo(1590)); + expect(clock.elapsedMs, lessThanOrEqualTo(1600)); }); } @@ -141,3 +151,31 @@ double _opacity(WidgetTester tester, int index) { } void _noop() {} + +Future _pumpHeartTime( + WidgetTester tester, + _HeartClock clock, + Duration duration, +) async { + const frameMs = 16; + var remainingMs = duration.inMilliseconds; + while (remainingMs > 0) { + final stepMs = remainingMs >= frameMs ? frameMs : remainingMs; + clock.advance(Duration(milliseconds: stepMs)); + await tester.pump(const Duration(milliseconds: frameMs)); + remainingMs -= stepMs; + } +} + +class _HeartClock { + DateTime _now = DateTime(2026, 10, 26, 12); + + DateTime now() => _now; + + int get elapsedMs => + _now.difference(DateTime(2026, 10, 26, 12)).inMilliseconds; + + void advance(Duration duration) { + _now = _now.add(duration); + } +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 1c4a022..854d6a8 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -680,6 +680,7 @@ void main() { } await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); + await _waitForBufferedHeartFrames(tester); expect(logic.session.userWantsPlay, isTrue); expect(find.byKey(const Key('player-play-toggle')), findsNothing); @@ -699,7 +700,7 @@ void main() { AppColors.doubleTapHeartRed, ); - await tester.pump(const Duration(milliseconds: 2300)); + await _waitForHeartCompletion(tester); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(logic.session.userWantsPlay, isTrue); }); @@ -723,6 +724,7 @@ void main() { await tester.tapAt(const Offset(80, 300)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); + await _waitForBufferedHeartFrames(tester); expect(logic.session.userWantsPlay, isTrue); expect(interactions.likeTargets, hasLength(1)); @@ -1818,6 +1820,20 @@ Future _clearFromMore(WidgetTester tester) async { await tester.pumpAndSettle(); } +Future _waitForBufferedHeartFrames(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 120)), + ); + await tester.pump(const Duration(milliseconds: 120)); +} + +Future _waitForHeartCompletion(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1700)), + ); + await tester.pump(const Duration(milliseconds: 16)); +} + Future _openSeriesPlayer( WidgetTester tester, { Key cardKey = const Key('home-feed-card-city-lights-demo'), diff --git a/test/player/player_hearts_pixels_test.dart b/test/player/player_hearts_pixels_test.dart index 704086b..44180ce 100644 --- a/test/player/player_hearts_pixels_test.dart +++ b/test/player/player_hearts_pixels_test.dart @@ -13,21 +13,26 @@ void main() { var completedCalls = 0; const origin = Offset(300, 546); + final clock = _HeartClock(); await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: PlayerDoubleTapHeartBurst( - burstId: 91, - origin: origin, - onCompleted: () => completedCalls++, + home: TickerMode( + enabled: false, + child: Scaffold( + body: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: PlayerDoubleTapHeartBurst( + burstId: 91, + origin: origin, + now: clock.now, + onCompleted: () => completedCalls++, + ), ), - ), - ], + ], + ), ), ), ), @@ -52,15 +57,56 @@ void main() { lessThanOrEqualTo(1), ); - await tester.pump(const Duration(milliseconds: 400)); - final midpoint = tester.getCenter(firstHeart); - debugPrint( - '[heart-pixels] after400ms center=${midpoint.dx},${midpoint.dy}', - ); - expect(midpoint.dy, lessThan(origin.dy - 40)); - expect((midpoint.dx - origin.dx).abs(), lessThanOrEqualTo(30)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 300)); + final center300 = tester.getCenter(firstHeart); + debugPrint('[heart-pixels] t=300 center=${center300.dx},${center300.dy}'); + expect(center300.dy, lessThan(initialCenter.dy)); + expect((center300.dx - origin.dx).abs(), lessThanOrEqualTo(30)); - await tester.pump(const Duration(milliseconds: 1200)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 300)); + final center600 = tester.getCenter(firstHeart); + debugPrint('[heart-pixels] t=600 center=${center600.dx},${center600.dy}'); + expect(center600.dy, lessThan(center300.dy)); + expect((center600.dx - origin.dx).abs(), lessThanOrEqualTo(30)); + + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 300)); + final center900 = tester.getCenter(firstHeart); + debugPrint('[heart-pixels] t=900 center=${center900.dx},${center900.dy}'); + expect(center900.dy, lessThan(center600.dy)); + expect((center900.dx - origin.dx).abs(), lessThanOrEqualTo(30)); + expect({ + initialCenter.dy, + center300.dy, + center600.dy, + center900.dy, + }, hasLength(4)); + + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 700)); expect(completedCalls, 1); }); } + +Future _pumpHeartTime( + WidgetTester tester, + _HeartClock clock, + Duration duration, +) async { + const frameMs = 16; + var remainingMs = duration.inMilliseconds; + while (remainingMs > 0) { + final stepMs = remainingMs >= frameMs ? frameMs : remainingMs; + clock.advance(Duration(milliseconds: stepMs)); + await tester.pump(const Duration(milliseconds: frameMs)); + remainingMs -= stepMs; + } +} + +class _HeartClock { + DateTime _now = DateTime(2026, 10, 26, 12); + + DateTime now() => _now; + + void advance(Duration duration) { + _now = _now.add(duration); + } +} From 1b50a73bfceb18b49530d80a394c97a6a8f965e3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 19:17:14 +0800 Subject: [PATCH 41/42] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E5=8F=8C=E5=87=BB=E7=82=B9=E8=B5=9E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 31 ++++++++ lib/pages/player/player_view_actions.dart | 56 ++++++++++++-- test/player/player_fullscreen_test.dart | 91 +++++++++++++++++++++-- 3 files changed, 165 insertions(+), 13 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 9b3a37f..c825021 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -519,9 +519,22 @@ class _PlayerPageState extends State Episode episode, ) { _lastDoubleTapAt = DateTime.now(); + if (kDebugMode) { + debugPrint( + '[player-like] phase=double-tap-callback ' + 'episodeId=${episode.id} ' + 'busy=${interactions.isEpisodeBusy(episode.id)} ' + 'liked=${interactions.isEpisodeLiked(episode.id)}', + ); + } unawaited(_toggleLike(context, interactions, episode, likeOnly: true)); } + void _refreshLikeState() { + if (!mounted) return; + setState(() {}); + } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { @@ -978,6 +991,9 @@ class _PlayerPageState extends State 'index=$index top=1 rail=1 progress=1', ); } + if (widget.homeMode && !_presentationActive && !theater) { + _logRailLikeState('home', episode.id, interactions); + } return Stack( fit: StackFit.expand, @@ -1209,6 +1225,7 @@ class _PlayerPageState extends State logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; final videoReady = videoPort?.controller.value.isInitialized ?? false; + _logRailLikeState('player', episode.id, interactions); return PlayerPlaybackOverlay( data: PlayerOverlayData( l10n: l10n, @@ -1270,6 +1287,20 @@ class _PlayerPageState extends State ); } + void _logRailLikeState( + String surface, + String episodeId, + InteractionController interactions, + ) { + if (!kDebugMode) return; + debugPrint( + '[player-like] phase=rail-state surface=$surface ' + 'episodeId=$episodeId ' + 'liked=${interactions.isEpisodeLiked(episodeId)} ' + 'count=${interactions.episodeLikeCount(episodeId)}', + ); + } + Future _presentFullPlayer(BuildContext context, int index) async { if (_presentationActive || _preparingFullPlayer || diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index e8af459..19f6667 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -99,18 +99,62 @@ extension _PlayerViewActions on _PlayerPageState { Episode episode, { bool likeOnly = false, }) async { + final busyBefore = interactions.isEpisodeBusy(episode.id); + final likedBefore = interactions.isEpisodeLiked(episode.id); + if (kDebugMode) { + debugPrint( + '[player-like] phase=toggle-start ' + 'episodeId=${episode.id} likeOnly=$likeOnly ' + 'busy=$busyBefore liked=$likedBefore', + ); + } unawaited(context.read().lightImpact()); - if (interactions.isEpisodeBusy(episode.id) || - (likeOnly && interactions.isEpisodeLiked(episode.id))) { + if (busyBefore || (likeOnly && likedBefore)) { + if (kDebugMode) { + debugPrint( + '[player-like] phase=toggle-skipped ' + 'episodeId=${episode.id} ' + 'reason=${busyBefore ? 'busy' : 'already-liked'}', + ); + } return; } - if (!await ensureSignedIn(context) || !context.mounted) return; - if (interactions.isEpisodeBusy(episode.id) || - (likeOnly && interactions.isEpisodeLiked(episode.id))) { + final signedIn = await ensureSignedIn(context); + if (kDebugMode) { + debugPrint( + '[player-like] phase=auth-result ' + 'episodeId=${episode.id} signedIn=$signedIn ' + 'mounted=${context.mounted}', + ); + } + if (!signedIn || !context.mounted) return; + final busyAfterAuth = interactions.isEpisodeBusy(episode.id); + final likedAfterAuth = interactions.isEpisodeLiked(episode.id); + if (busyAfterAuth || (likeOnly && likedAfterAuth)) { + if (kDebugMode) { + debugPrint( + '[player-like] phase=toggle-skipped-after-auth ' + 'episodeId=${episode.id} ' + 'reason=${busyAfterAuth ? 'busy' : 'already-liked'}', + ); + } return; } final result = await interactions.toggleEpisodeLike(episode.id); - if (result.isSuccess || !context.mounted) return; + if (kDebugMode) { + debugPrint( + '[player-like] phase=toggle-result ' + 'episodeId=${episode.id} success=${result.isSuccess} ' + 'liked=${interactions.isEpisodeLiked(episode.id)} ' + 'count=${interactions.episodeLikeCount(episode.id)} ' + 'error=${result.errorMessage ?? '-'}', + ); + } + if (result.isSuccess) { + _refreshLikeState(); + return; + } + if (!context.mounted) return; showCenteredToast( context, result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 854d6a8..784bb47 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -16,6 +17,7 @@ import 'package:fruitshorts/core/data/models/player_port.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/interaction_controller.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; @@ -655,7 +657,54 @@ void main() { expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); - testWidgets('should_double_tap_like_once_without_pausing_full_player', ( + testWidgets('should_like_once_on_first_home_feed_double_tap', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + final interactions = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 23001), + ); + await _pumpApp(tester, interactions: interactions); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + final page = find.byKey(const Key('recommend-feed-player')); + final logic = _pageLogic(tester, page); + final episode = logic.queue.entryAt(logic.session.currentIndex).episode; + final gesture = find.byKey(const Key('home-feed-gesture')).hitTestable(); + final position = tester.getCenter(gesture); + final interactionController = Provider.of( + tester.element(gesture), + listen: false, + ); + + await tester.tapAt(position); + await tester.tapAt(position); + await tester.pump(); + await tester.pump(); + + expect(interactionController.isEpisodeLiked(episode.id), isTrue); + expect(interactionController.episodeLikeCount(episode.id), 23001); + expect(_railLabel(tester, const Key('rail-like')), '2.3万'); + expect(logic.session.userWantsPlay, isTrue); + expect(interactions.likeTargets, ['EPISODE:${episode.id}']); + + await _waitForDoubleTapWindow(tester); + await tester.tapAt(position); + await tester.tapAt(position); + await tester.pump(); + await tester.pump(); + + expect(interactions.likeTargets, hasLength(1)); + expect(interactionController.episodeLikeCount(episode.id), 23001); + expect(logic.session.userWantsPlay, isTrue); + await _waitForHeartCompletion(tester); + }); + + testWidgets('should_like_once_on_first_double_tap_without_pausing', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -665,7 +714,7 @@ void main() { 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); final interactions = FakeInteractionRepository( - nextLikeResult: const LikeToggleResult(liked: true, count: 23000), + nextLikeResult: const LikeToggleResult(liked: true, count: 23001), ); await _pumpApp(tester, interactions: interactions); await _openSeriesPlayer(tester); @@ -673,18 +722,24 @@ void main() { final logic = _pageLogic(tester, find.byType(PlayerPage).last); final gesture = find.byKey(const Key('player-video-tap')).hitTestable(); final position = tester.getCenter(gesture); + final interactionController = Provider.of( + tester.element(gesture), + listen: false, + ); expect(logic.session.userWantsPlay, isTrue); - for (var index = 0; index < 4; index++) { - await tester.tapAt(position); - } + await tester.tapAt(position); + await tester.tapAt(position); + await tester.pump(); await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); - await _waitForBufferedHeartFrames(tester); expect(logic.session.userWantsPlay, isTrue); expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']); + expect(interactionController.isEpisodeLiked('city-lights-demo-1'), isTrue); + expect(interactionController.episodeLikeCount('city-lights-demo-1'), 23001); + expect(_railLabel(tester, const Key('player-rail-like')), '2.3万'); + await _waitForBufferedHeartFrames(tester); for (var index = 0; index < 3; index++) { expect(find.byKey(Key('double-tap-heart-$index')), findsOneWidget); } @@ -700,6 +755,15 @@ void main() { AppColors.doubleTapHeartRed, ); + await _waitForDoubleTapWindow(tester); + await tester.tapAt(position); + await tester.tapAt(position); + await tester.pump(); + await tester.pump(); + + expect(interactions.likeTargets, hasLength(1)); + expect(interactionController.episodeLikeCount('city-lights-demo-1'), 23001); + expect(logic.session.userWantsPlay, isTrue); await _waitForHeartCompletion(tester); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(logic.session.userWantsPlay, isTrue); @@ -1827,6 +1891,13 @@ Future _waitForBufferedHeartFrames(WidgetTester tester) async { await tester.pump(const Duration(milliseconds: 120)); } +Future _waitForDoubleTapWindow(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 360)), + ); + await tester.pump(); +} + Future _waitForHeartCompletion(WidgetTester tester) async { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 1700)), @@ -1834,6 +1905,12 @@ Future _waitForHeartCompletion(WidgetTester tester) async { await tester.pump(const Duration(milliseconds: 16)); } +String _railLabel(WidgetTester tester, Key key) => tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Text)), + ) + .data!; + Future _openSeriesPlayer( WidgetTester tester, { Key cardKey = const Key('home-feed-card-city-lights-demo'), From afdc4af2f2adbc3016aac6da51557060230690eb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 19:21:24 +0800 Subject: [PATCH 42/42] =?UTF-8?q?chore(player):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=A3=98=E5=BF=83=E6=AF=AB=E7=A7=92=E8=B0=83=E8=AF=95=E6=96=87?= =?UTF-8?q?=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/player_double_tap_hearts.dart | 12 ------------ test/player/player_double_tap_hearts_test.dart | 12 ------------ test/player/player_hearts_pixels_test.dart | 6 ------ 3 files changed, 30 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 1d51a9d..d5493fa 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -159,18 +159,6 @@ class _PlayerDoubleTapHeartBurstState extends State { children: [ for (var index = 0; index < _hearts.length; index++) _heart(_hearts[index], _elapsedMs.toDouble(), index), - if (kDebugMode) - Positioned( - left: widget.origin.dx + 8, - top: widget.origin.dy + 10, - child: IgnorePointer( - child: Text( - '${_elapsedMs}ms', - key: const Key('heart-burst-elapsed-debug'), - style: const TextStyle(color: Colors.white60, fontSize: 11), - ), - ), - ), ], ), ); diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 61776c4..dbd3de9 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -26,11 +26,6 @@ void main() { expect(_size(tester, 0), 48); expect(_size(tester, 1), 36); expect(_size(tester, 2), 40); - final elapsedText = tester - .widget(find.byKey(const Key('heart-burst-elapsed-debug'))) - .data!; - final elapsedMs = int.parse(elapsedText.replaceFirst('ms', '')); - expect(elapsedMs, inInclusiveRange(180, 220)); expect( tester .widgetList( @@ -97,13 +92,6 @@ void main() { (tester.getCenter(firstHeart) - origin).distance, lessThanOrEqualTo(1), ); - final debugTopLeft = tester.getTopLeft( - find.byKey(const Key('heart-burst-elapsed-debug')), - ); - expect( - (debugTopLeft - const Offset(308, 556)).distance, - lessThanOrEqualTo(1), - ); await _pumpHeartTime(tester, clock, const Duration(milliseconds: 750)); final midpoint = tester.getCenter(firstHeart); diff --git a/test/player/player_hearts_pixels_test.dart b/test/player/player_hearts_pixels_test.dart index 44180ce..1aefe69 100644 --- a/test/player/player_hearts_pixels_test.dart +++ b/test/player/player_hearts_pixels_test.dart @@ -50,12 +50,6 @@ void main() { expect(firstSize.height, closeTo(48, 1)); expect(firstSize.width, greaterThan(20)); expect((initialCenter - origin).distance, lessThanOrEqualTo(1)); - expect( - (tester.getTopLeft(find.byKey(const Key('heart-burst-elapsed-debug'))) - - const Offset(308, 556)) - .distance, - lessThanOrEqualTo(1), - ); await _pumpHeartTime(tester, clock, const Duration(milliseconds: 300)); final center300 = tester.getCenter(firstHeart);