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/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/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart new file mode 100644 index 0000000..2d54d46 --- /dev/null +++ b/lib/core/data/services/playback_neighbors.dart @@ -0,0 +1,207 @@ +// 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; + 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; + 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, roleHint: 'old-current'); + await _placeNeighbor(session, warmNext, roleHint: 'next'); + await _placeNeighbor(session, warmPrevious, roleHint: 'previous'); +} + +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); + 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, trigger: 'park-neighbor', roleHint: roleHint); +} + +/// 当前集已经出画后,按手势方向静默准备上一集和下一集。 +void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) { + final request = session._generation; + 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( + 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, + trigger: 'warm-failure', + roleHint: next ? 'next' : 'previous', + ); + }); +} + +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, + trigger: 'warm-slot-invalid', + roleHint: next ? 'next' : 'previous', + ); + } + 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..205a6cd 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -7,6 +7,9 @@ import '../models/drama.dart'; 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); @@ -14,37 +17,9 @@ 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(); +enum PlaybackSessionOwner { home, player } - 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, @@ -54,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; @@ -66,16 +43,20 @@ 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; _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; @@ -105,7 +86,11 @@ 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; Future get playbackSettled => _operations; Future get preloadSettled => _preloadTask; @@ -120,7 +105,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 || @@ -132,34 +117,41 @@ 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}) { + 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, trigger: 'remap-stale-next', roleHint: 'next'); + await _retire( + stalePrevious, + trigger: 'remap-stale-previous', + roleHint: 'previous', + ); + }), + ); + if (_visible && !_overlayOpen) _warmNeighbors(this); + } + + void _rememberNeighbors(int? preloadIndex, int? previousIndex) { + _nextIndex = preloadIndex; + _previousIndex = previousIndex; } Future _enqueue(Future Function() operation) { @@ -176,8 +168,15 @@ class PlaybackSession extends ChangeNotifier { } _PlayerSlot _create(int index) { - if (_live.length >= 2) throw StateError('Playback resource limit reached'); - final slot = _PlayerSlot(index, factory(episodes[index])); + if (_live.length >= maxLivePlayers) { + throw StateError('Playback resource limit reached'); + } + final slot = _PlayerSlot( + index, + factory(episodes[index]), + owner: owner, + dramaId: dramaIdAt?.call(index), + ); _live.add(slot); slot.ready = () async { try { @@ -195,12 +194,32 @@ class PlaybackSession extends ChangeNotifier { return slot; } - Future _retire(_PlayerSlot? slot) async { - if (slot == null || !_live.contains(slot)) return; + 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; + 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 || _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(); @@ -210,22 +229,41 @@ 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', + ); + } } } /// 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, + 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; _selected = true; _currentIndex = index; - _nextIndex = preloadIndex; + _rememberNeighbors(preloadIndex, previousIndex); _error = null; if (!_visible) { _loading = false; @@ -250,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 && @@ -259,20 +297,27 @@ class PlaybackSession extends ChangeNotifier { target = old; return; } - if (old != null) await old.player.pause(); - final warm = _next; + if (old != null) { + await old.player.pause(); + old.playing = false; + } + 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) { + warm.reusedWarm = true; 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 +330,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); } /// 后台准备新片源,成功后再替换当前播放器。失败时原播放器保持不动。 @@ -314,9 +359,11 @@ 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) _schedulePreload(); + if (!_closed && request == _generation) _warmNeighbors(this); return _active == slot && slot.failure == null; } @@ -326,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); @@ -354,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'); + } }); } @@ -369,7 +418,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,94 +474,31 @@ 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(); - } - - 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) { - 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()) { - await _retire(slot); + await _retire(slot, trigger: 'session-release-all'); } }); } @@ -518,6 +507,23 @@ 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( + '[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; @@ -528,13 +534,27 @@ 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; bool playing = false; bool retiring = false; + bool reusedWarm = false; Object? failure; } + +String _playerIdentity(PlayerPort player) { + return player is VideoPlayerPort + ? '${identityHashCode(player.controller)}' + : '${identityHashCode(player)}'; +} 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..fbb7b10 --- /dev/null +++ b/lib/core/data/services/playback_session_controls.dart @@ -0,0 +1,133 @@ +// 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); + } + + /// 路由交接时同步暂停当前原生播放器,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 (_visible && !_overlayOpen && !_loading) _warmNeighbors(this); + if (kDebugMode) { + debugPrint( + '[feed-handoff] phase=resumed activated=${slot.activated} ' + 'playing=${slot.playing}', + ); + } + }); + } + + /// 选中锁定集时立即停播并释放所有原生播放器,不创建新片源。 + 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; + if (_closed || slot == null || slot.retiring || slot.index != index) { + return null; + } + 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; + _syncPlayback(); + } + + /// 同步播放器路由是否可见。 + void setRouteVisible(bool value) { + _routeVisible = value; + _syncPlayback(); + } + + /// 同步浮层是否遮挡播放器。 + void setOverlayOpen(bool value) { + _overlayOpen = value; + _syncPlayback(); + } + + /// 记录用户播放意图;切换剧集时会在 [PlaybackSession.select] 中恢复播放。 + void setUserWantsPlay(bool value) { + _userWantsPlay = value; + _syncPlayback(); + _notify(); + } +} diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart index 55f10da..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 { @@ -132,36 +132,36 @@ 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,拖动帧不触发网络或解码。 + /// 播放器就绪时预取当前 cue;容量大于 1 时顺带预取相邻 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 +170,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 +180,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 +208,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 +234,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier } on SpriteThumbnailForbiddenException { if (_disposed) return SpriteThumbnailAxis.empty(url); _axisCache.remove(url); - if (_activeAxisUrl == url) _clearSprites(); } catch (_) { break; } @@ -274,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; @@ -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/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/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/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/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 6c47780..ac76aa9 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'; @@ -93,6 +94,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; @@ -100,6 +104,7 @@ class PlayerLogic extends ChangeNotifier { bool pip = false; bool pipPreparing = false; final Map pickedEpisode = {}; + final Set _unlockedEpisodeIds = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; @@ -188,6 +193,7 @@ class PlayerLogic extends ChangeNotifier { void start({ required PlayerFactory factory, required PlaybackCoordinator coordinator, + bool initialUserWantsPlay = true, }) { _started = true; _sessionFactory = factory; @@ -205,7 +211,46 @@ class PlayerLogic extends ChangeNotifier { session.setRouteVisible(false); } unawaited(loadResumePoint()); - unawaited(selectEpisode(initialIndex)); + unawaited( + selectEpisode( + initialIndex, + userWantsPlay: initialUserWantsPlay ? null : false, + ), + ); + } + + /// 独立播放页覆盖首页时只暂停并保存进度,首页 session 与 controller 保持存活。 + Future pauseForFullPlayer() async { + if (!_started) return; + _logFeedHandoff('pause'); + cancelScrub(); + await session.pauseForRouteHandoff(); + _logFeedHandoff('paused'); + } + + /// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。 + 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 个,刷新时尽量保留当前剧。 @@ -238,11 +283,16 @@ 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, waitForFrame: _waitForFrame, + owner: homeMode ? PlaybackSessionOwner.home : PlaybackSessionOwner.player, + dramaIdAt: (index) => index >= 0 && index < queue.length + ? queue.entryAt(index).drama.id + : null, ); } @@ -299,6 +349,7 @@ class PlayerLogic extends ChangeNotifier { session.remapCurrentIndex( keepIndex, preloadIndex: nextVisibleIndex(keepIndex), + previousIndex: previousVisibleIndex(keepIndex), ); _jumpToFeedPage(keepIndex); _notifyAfterFrameIfNeeded(); @@ -427,6 +478,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 []; @@ -451,12 +503,14 @@ 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, likeCount: ticket.likeCount, commentCount: ticket.commentCount, thumbnailsUrl: ticket.thumbnailsUrl, + posterUrl: ticket.posterUrl, ); return true; } @@ -466,7 +520,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; } @@ -530,6 +584,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)); @@ -564,9 +619,10 @@ class PlayerLogic extends ChangeNotifier { unlockError = service.unlockError; return false; } + _unlockedEpisodeIds.add(episode.id); final ready = await ensureTicket(index, forceRefresh: true); if (ready) { - await session.select(index, preloadIndex: nextVisibleIndex(index)); + await _selectVisible(index); } return ready; } finally { @@ -949,7 +1005,42 @@ class PlayerLogic extends ChangeNotifier { ); } - Future selectEpisode(int index, {int? revision}) async { + /// 翻页入口使用 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, + bool? userWantsPlay, + }) async { final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; cancelScrub(); @@ -960,6 +1051,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 || @@ -967,8 +1074,24 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } - if (!ready && ticketError != null) return; - await session.select(index, preloadIndex: nextVisibleIndex(index)); + if (!ready) return; + await _selectVisible(index, userWantsPlay: userWantsPlay); + } + + /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 + bool isEpisodeLocked(Episode episode) { + return (episode.locked || !episode.isFree) && + !_unlockedEpisodeIds.contains(episode.id); + } + + /// 按可见队列选择一集,并预热上一集与下一集。 + Future _selectVisible(int index, {bool? userWantsPlay}) { + return session.select( + index, + preloadIndex: nextVisibleIndex(index), + previousIndex: previousVisibleIndex(index), + userWantsPlay: userWantsPlay, + ); } int? nextVisibleIndex(int index) { @@ -978,6 +1101,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 { @@ -1095,12 +1225,10 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { - session.setRouteVisible(false); - await session.playbackSettled; - await saveCurrentProgress(); - if (!context.mounted) return; - await Navigator.of(context).push( + final route = Navigator.of(context).push( FadePageRoute( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, builder: (_) => PlayerPage( drama: drama, initialIndex: index, @@ -1109,7 +1237,14 @@ class PlayerLogic extends ChangeNotifier { ), ), ); - if (isMounted()) session.setRouteVisible(active); + if (kDebugMode) { + debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0'); + } + unawaited(pauseForFullPlayer()); + unawaited(saveCurrentProgress()); + await route; + if (!isMounted()) return; + await resumeAfterFullPlayer(); } Future showEpisodes(BuildContext context) async { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f111dee..c825021 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'; @@ -9,6 +10,7 @@ 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'; @@ -36,11 +38,15 @@ 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_heart_overlay_scope.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'; import 'widgets/player_video_surface.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -52,6 +58,12 @@ const double _playerLoadingSize = 24; const double _playerLoadingStrokeWidth = 2; const double _feedRefreshTopGap = 56; +class PlayerPageHandoff { + PlayerPageHandoff({required this.sourceUserWantsPlay}); + + final bool sourceUserWantsPlay; +} + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -79,6 +91,7 @@ class PlayerPage extends StatefulWidget { this.onChannelSelected, this.wallet, this.downloads, + this.handoff, }); final Drama drama; @@ -105,6 +118,7 @@ class PlayerPage extends StatefulWidget { final ValueChanged? onChannelSelected; final WalletController? wallet; final DownloadController? downloads; + final PlayerPageHandoff? handoff; @override State createState() => _PlayerPageState(); @@ -113,6 +127,7 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; + late PageController _pageController; late final PlaybackThumbnailService _thumbnailService; late final SpriteThumbnailAxisService _spriteAxisService; PageRoute? _subscribedRoute; @@ -123,6 +138,19 @@ class _PlayerPageState extends State double? _speedBeforeBoost; bool? _chromeBeforeBoost; DateTime? _lastDoubleTapAt; + DateTime? _swipeStartedAt; + bool _swipePrewarmCaptured = false; + bool _presentationActive = false; + bool _leavingHandoff = false; + bool _preparingFullPlayer = false; + int? _videoReadyIndex; + bool _loadingVisible = false; + bool _loadingRemoved = true; + bool _loadingFadeScheduled = false; + bool _loggedLoadingUiVisible = false; + final PlayerHeartOverlayController _heartOverlayController = + PlayerHeartOverlayController(); + final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -131,6 +159,7 @@ class _PlayerPageState extends State @override void initState() { super.initState(); + final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), playback: context.read(), @@ -142,12 +171,28 @@ 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(), - coordinator: context.read(), + coordinator: widget.embedded + ? context.read() + : PlaybackCoordinator(), + initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, ); + _pageController = logic.pages; + 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}', + ); + } + } } @override @@ -250,11 +295,13 @@ class _PlayerPageState extends State @override void didPushNext() { + if (_presentationActive) return; logic.onRouteCovered(); } @override void didPopNext() { + if (_presentationActive) return; logic.onRouteRevealed(); } @@ -262,7 +309,10 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); - _spriteAxisService.dispose(); + _heartOverlayController.dispose(); + _spriteAxisService + ..removeListener(_handleSpriteFrameChanged) + ..dispose(); _thumbnailService.dispose(); logic.dispose(); super.dispose(); @@ -307,6 +357,43 @@ class _PlayerPageState extends State unawaited(logic.setPlaybackSpeed(speed)); } + bool _handlePageScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification && + 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) { + 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; @@ -317,6 +404,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; @@ -337,6 +446,44 @@ class _PlayerPageState extends State _chromeVisible = true; } + void _handleSpriteFrameChanged() { + if (mounted) setState(() {}); + } + + void _prewarmAdjacentFrames() { + 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 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)); + } + } + } + void _restoreFeedChrome() { if (!mounted) return; setState(() { @@ -372,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(() { @@ -416,46 +576,116 @@ class _PlayerPageState extends State final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; + 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 || + logic.session.error != null || + logic.isEpisodeLocked(logic.session.currentEpisode); + if (!widget.embedded && + !_loadingRemoved && + !_loadingFadeScheduled && + entryUnavailable && + !canWaitForVideoFrame) { + _scheduleLoadingFade('entry-unavailable'); + } + if (!widget.embedded && + !_loadingRemoved && + !_loadingFadeScheduled && + activePort != null && + activePort is! VideoPlayerPort) { + _scheduleLoadingFade('non-video-player-ready'); + } 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), + _prewarmAdjacentFrames(); + final pages = NotificationListener( + onNotification: _handlePageScroll, + child: PageView.builder( + controller: _pageController, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + physics: widget.onRefreshFeed == null + ? null + : const FeedRefreshPhysics(), + 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) { + final loadMore = widget.onLoadMore; + if (loadMore != null) unawaited(loadMore()); + } + }, + 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: _pageController, + topInset: + MediaQuery.paddingOf(context).top + _feedRefreshTopGap, + onRefresh: _refreshHomeFeed, + child: pages, + ); + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + PlayerRenderStack( + pageController: _pageController, + slots: _renderSlots(indices), + onCurrentVideoVisible: _handleCurrentVideoVisible, + ), + if (!widget.embedded && !_loadingRemoved) + Center( + child: IgnorePointer( + key: const Key('player-entry-loading-overlay'), + child: PlayerLoadingOverlay( + opacity: _loadingVisible ? 1 : 0, + onFadeEnd: _handleLoadingFadeEnd, + ), + ), + ), + pageLayer, + ], ); }, ); final body = Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ content, if (widget.homeMode && widget.showHomeFeedHeader && !_scrubbing && - _chromeVisible && !_boosting && !logic.pip && !logic.pipPreparing) @@ -488,7 +718,11 @@ class _PlayerPageState extends State ), ], ); - return AnnotatedRegion( + final scopedBody = PlayerHeartOverlayScope( + controller: _heartOverlayController, + child: body, + ); + final player = AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( statusBarColor: AppColors.transparent, @@ -500,15 +734,83 @@ 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, ), ), ); + if (widget.handoff == null) return player; + return PopScope( + canPop: _leavingHandoff, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(_leaveHandoff(context)); + }, + child: player, + ); + } + + void _handleCurrentVideoVisible(int? index) { + if (!mounted || _loadingRemoved) 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 ' + 'session=${identityHashCode(logic.session)}', + ); + } + 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); + }); + }); + } + + void _scheduleLoadingFade(String reason) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _startLoadingFade(logic.session.currentIndex, reason: reason); + }); + } + + void _startLoadingFade(int index, {String reason = 'video-first-frame'}) { + if (!mounted || + widget.embedded || + index != logic.session.currentIndex || + !_loadingVisible || + _loadingRemoved || + _loadingFadeScheduled) { + return; + } + _loadingFadeScheduled = true; + if (kDebugMode) { + debugPrint('[player-entry] phase=loading-fade-start reason=$reason'); + } + setState(() => _loadingVisible = false); + } + + void _handleLoadingFadeEnd() { + if (!mounted || _loadingVisible || _loadingRemoved) return; + setState(() => _loadingRemoved = true); + if (kDebugMode) { + debugPrint('[player-entry] phase=loading-removed'); + } } void _openCurrentMore() { @@ -516,6 +818,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, @@ -524,58 +965,43 @@ 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 episode = - (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && - sessionEpisode.thumbnailsUrl != null - ? displayed.withVideoUrl( - displayed.videoUrl, - thumbnailsUrl: sessionEpisode.thumbnailsUrl, - ) - : displayed; + final episode = _resolvedEpisode(index); 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 handoffPort = handoff is VideoPlayerPort ? handoff : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; - final ready = videoReady || port != null; + if (selected && videoReady) _logSwipeReveal(); + final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; - final locked = logic.session.isLocked || episode.locked; - if (selected && videoPort != null && videoPort.controller.value.isInitialized) { - unawaited( - _spriteAxisService.prepareAt( - episode.thumbnailsUrl, - videoPort.controller.value.position, - ), + 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', ); } + if (widget.homeMode && !_presentationActive && !theater) { + _logRailLikeState('home', episode.id, interactions); + } return Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ - if (widget.homeMode) - const ColoredBox( - key: Key('home-feed-black-background'), - color: AppColors.mediaBlack, - ), - PlayerVideoSurface( - controller: videoPort?.controller, - handoff: handoffPort?.controller, - videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, - theater: theater, - ), if (selected && + !entryLoading && + !locked && !theater && !ready && !logic.ticketLoading && @@ -594,7 +1020,7 @@ class _PlayerPageState extends State ), ), // 票据三态之一:起播前正在向后端要签名地址 - if (selected && !theater && logic.ticketLoading) + if (selected && !entryLoading && !theater && logic.ticketLoading) const Center( child: SizedBox.square( dimension: _playerLoadingSize, @@ -690,7 +1116,7 @@ class _PlayerPageState extends State ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (widget.homeMode) + else if (widget.homeMode && !_presentationActive) HomeFeedOverlay( l10n: l10n, drama: drama, @@ -719,14 +1145,14 @@ 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, - onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) : null, + onScrubStart: videoReady + ? () => logic.beginScrub(videoPort!) + : null, onScrubEnd: _finishFeedScrub, onScrubVisibilityChanged: _setScrubbing, cancelScrubRequest: widget.scrubCancelRequest, @@ -736,69 +1162,10 @@ 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 && - !ready && - logic.blockedEpisodeId == episode.id) + if (selected && !theater && locked) PlayerBlockedNotice( l10n: l10n, episode: episode, @@ -836,4 +1203,192 @@ class _PlayerPageState extends State ], ); } + + Widget _standardPlaybackOverlay( + BuildContext context, + int index, + InteractionController interactions, + ) { + 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; + _logRailLikeState('player', episode.id, interactions); + 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: widget.handoff != null + ? () => unawaited(_leaveHandoff(context)) + : widget.embedded + ? 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)), + 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, + ); + } + + 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 || + !_pageController.hasClients) { + return; + } + _preparingFullPlayer = true; + final sourceIndex = logic.session.currentIndex; + final sourceEpisode = logic.session.currentEpisode; + final sourceQueue = logic.queue; + 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 sourceUserWantsPlay = logic.session.userWantsPlay; + final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay); + _logFullPlayerRoute('present'); + setState(() => _presentationActive = true); + final route = Navigator.of(context).push( + PageRouteBuilder( + opaque: true, + barrierDismissible: false, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => PlayerPage( + drama: seriesDrama, + initialIndex: episodeIndex, + handoff: handoff, + wallet: logic.wallet, + downloads: logic.downloads, + ), + ), + ); + if (kDebugMode) { + debugPrint('[player-entry] phase=route-pushed source=full-player ms=0'); + } + unawaited(logic.pauseForFullPlayer()); + unawaited(logic.saveCurrentProgress()); + await route; + if (!mounted) return; + logic.attach(widget, isMounted: () => mounted); + setState(() => _presentationActive = false); + await logic.resumeAfterFullPlayer(); + if (!mounted) return; + _logFullPlayerRoute('restored'); + } + + Future _leaveHandoff(BuildContext context) async { + if (_leavingHandoff) return; + setState(() => _leavingHandoff = true); + await logic.session.pauseForRouteHandoff(); + if (context.mounted) Navigator.of(context).pop(); + } + + 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 ?? '-'}', + ); + } } 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/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 338004d..15411ee 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,35 +121,24 @@ class _HomeFeedOverlayState extends State { } } - @override - void dispose() { - _tapSeq++; - _singleTapTimer?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { return SafeArea( bottom: false, child: Stack( + clipBehavior: Clip.none, 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( + key: const ValueKey('home-feed-tap-gesture-region'), + gestureKey: const Key('home-feed-gesture'), + enabled: !_scrubbing, + doubleTapEnabled: !widget.locked, + now: widget.now, + onSingleTap: widget.onTogglePlay, + onDoubleTapLike: widget.onDoubleTapLike, + onLongPressStart: _handleLongPressStart, + onInteractionEnd: widget.onBoostEnd, ), ), Positioned( @@ -217,31 +195,11 @@ 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, ), ), - 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), - ), - ), - ], - ), - ), - ), ], ), ); @@ -263,83 +221,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/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_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 568c86e..d5493fa 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,13 +1,16 @@ // Author: fruit.com.cn +import 'dart:async'; + +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,75 +38,136 @@ 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, + 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(); - final total = _hearts + _lifetime.start(); + _startAt = _now(); + _totalMs = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); - _controller = AnimationController( - vsync: this, - duration: Duration(milliseconds: total), - )..addStatusListener(_onStatus); - _controller.forward(); + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-start ' + 'id=${widget.burstId} count=${_hearts.length} ' + 'configuredMs=$_totalMs at=${widget.origin.dx.toStringAsFixed(1)},' + '${widget.origin.dy.toStringAsFixed(1)}', + ); + } + _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=$_totalMs', + ); + } widget.onCompleted(); } @override void dispose() { - _controller - ..removeStatusListener(_onStatus) - ..dispose(); + if (kDebugMode) { + final elapsedMs = _completed ? _totalMs : _elapsedMs; + debugPrint( + '[player] phase=heart-burst-dispose ' + 'id=${widget.burstId} elapsedMs=$elapsedMs ' + 'completed=${_completed ? 1 : 0}', + ); + if (!_completed) { + debugPrintStack( + maxFrames: 30, + label: + '[player] heart-burst-premature-dispose ' + 'id=${widget.burstId} mount=root-overlay', + ); + } + } + _lifetime.stop(); + _timer?.cancel(); + _timer = null; super.dispose(); } @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 < _hearts.length; index++) - _heart(_hearts[index], elapsed, index), - ], - ); - }, - ), + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _hearts.length; index++) + _heart(_hearts[index], _elapsedMs.toDouble(), index), + ], ), ); } 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 + @@ -113,8 +177,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_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_loading_overlay.dart b/lib/pages/player/widgets/player_loading_overlay.dart new file mode 100644 index 0000000..a7ce449 --- /dev/null +++ b/lib/pages/player/widgets/player_loading_overlay.dart @@ -0,0 +1,45 @@ +// 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 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_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart new file mode 100644 index 0000000..2841056 --- /dev/null +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -0,0 +1,177 @@ +// Author: fruit.com.cn + +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 '../../../core/widgets/remote_image.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 thumbnails = thumbnailsUrl?.trim() ?? ''; + final poster = posterUrl?.trim() ?? ''; + if (playerOpeningFallback(posterUrl: poster, thumbnailsUrl: thumbnails) == + PlayerOpeningFallback.black) { + return null; + } + return PlayerOpeningFrame( + thumbnailsUrl: thumbnails, + posterUrl: poster, + spriteSource: spriteSource, + fit: fit, + ); +} + +/// 与视频共用全屏父约束和 BoxFit,切换时保持相同可见区域。 +class PlayerOpeningFrame extends StatefulWidget { + const PlayerOpeningFrame({ + super.key, + required this.thumbnailsUrl, + required this.spriteSource, + this.posterUrl, + this.fit = BoxFit.cover, + }); + + /// 当前视频原始分辨率首帧地址,优先于雪碧图。 + final String? posterUrl; + + /// 当前视频雪碧图 WebVTT 地址。 + final String thumbnailsUrl; + + /// 进度条拖动预览共用的雪碧图时间轴与解码缓存。 + final SpriteThumbnailAxisSource spriteSource; + + /// 必须与同页视频层使用相同适配规则。 + final BoxFit fit; + + @override + State createState() => _PlayerOpeningFrameState(); +} + +class _PlayerOpeningFrameState extends State { + bool _posterFailed = false; + + @override + void initState() { + super.initState(); + widget.spriteSource.addListener(_handleSpriteChanged); + _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.posterUrl != widget.posterUrl || + oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { + _prepareSpriteIfNeeded(); + } + } + + @override + void dispose() { + widget.spriteSource.removeListener(_handleSpriteChanged); + super.dispose(); + } + + void _handleSpriteChanged() { + 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 (_hasPoster) + 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/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index c9612b9..11ff157 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'; @@ -54,18 +55,18 @@ class PlayerPlaybackOverlay extends StatelessWidget { Widget build(BuildContext context) { return SafeArea( child: Stack( + clipBehavior: Clip.none, 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( + key: const ValueKey('player-tap-gesture-region'), + gestureKey: const Key('player-video-tap'), + enabled: !scrubbing, + doubleTapEnabled: !data.locked, + onSingleTap: actions.onTogglePlay, + onDoubleTapLike: actions.onDoubleTapLike, + onLongPressStart: _handleLongPressStart, + onInteractionEnd: actions.onBoostEnd, ), ), _top(context), @@ -303,8 +304,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { spriteAxisService: data.spriteAxisService, thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, - posterUrl: data.drama.posterUrl, 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_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_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart new file mode 100644 index 0000000..4cf3c56 --- /dev/null +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -0,0 +1,214 @@ +// 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, + this.onCurrentVideoVisible, + }); + + final PageController pageController; + final Map slots; + final ValueChanged? onCurrentVideoVisible; + + @override + State createState() => _PlayerRenderStackState(); +} + +class _PlayerRenderStackState extends State { + int? _lastCurrentIndex; + + @override + void initState() { + super.initState(); + _lastCurrentIndex = _currentSlot.index; + _log( + 'init current=${_lastCurrentIndex ?? '-'} ' + 'pageController=${identityHashCode(widget.pageController)}', + ); + } + + @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 ?? '-'} ' + 'pageController=${identityHashCode(widget.pageController)}', + ); + 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, + onVideoVisible: slot.role == PlayerRenderSlotRole.current + ? () => widget.onCurrentVideoVisible?.call(slot.index) + : null, + ), + ), + ); + } + + void _log(String message) { + if (!kDebugMode) return; + debugPrint( + '[player-render] ts=${DateTime.now().millisecondsSinceEpoch} $message', + ); + } +} 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..032d159 --- /dev/null +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -0,0 +1,196 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +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); + +/// 播放画面共用的单击/双击仲裁层。 +class PlayerTapGestureRegion extends StatefulWidget { + const PlayerTapGestureRegion({ + super.key, + required this.gestureKey, + required this.onSingleTap, + required this.onLongPressStart, + required this.onInteractionEnd, + this.onDoubleTapLike, + this.doubleTapEnabled = true, + this.enabled = true, + this.now, + }); + + final Key gestureKey; + 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; + + @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, + clipBehavior: Clip.none, + children: [ + GestureDetector( + key: widget.gestureKey, + behavior: HitTestBehavior.opaque, + onTapUp: _handleTapUp, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => widget.onInteractionEnd(), + onLongPressCancel: widget.onInteractionEnd, + ), + ], + ), + ), + ), + ); + } + + 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.globalPosition); + 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 globalPosition) { + _blockSingleTapUntil = _now().add(_singleTapDelay); + _cancelPendingSingleTap(); + if (!widget.doubleTapEnabled || _likedThisPair) return; + _likedThisPair = true; + _showHeartBurst(globalPosition); + 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++; + 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( + child: PlayerDoubleTapHeartBurst( + key: ValueKey('heart-overlay-$id'), + burstId: id, + origin: origin, + now: widget.now, + onCompleted: removeEntry, + ), + ), + ), + ); + controller?.track(id, entry); + overlay.insert(entry); + } + + void _handleLongPressStart(LongPressStartDetails details, double width) { + _tapSeq++; + _cancelPendingSingleTap(); + _doubleTapDown = false; + _lastDownAt = null; + widget.onLongPressStart(details, width); + } +} diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 62bfb64..49f52f6 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -5,80 +5,180 @@ 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 _videoFadeDuration = Duration(milliseconds: 200); +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.placeholder, + this.warmFrameAvailable = false, + this.thumbnailReady = false, + this.posterReady = false, + this.fallback = PlayerOpeningFallback.black, + this.warmSlotReused = false, + this.waitForPlaybackFrame = false, + this.debugSlot = 'default', + this.debugIndex, + this.showFallbackBackground = true, + this.onVideoVisible, }); final VideoPlayerController? controller; - final VideoPlayerController? handoff; + + /// 视频首帧未确认时唯一的占位 widget。 + final Widget? placeholder; + + /// 相邻集预热槽已可用时直接出视频,不挂载占位。 + final bool warmFrameAvailable; + + /// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。 + final bool thumbnailReady; + + /// 当前集高清 poster 是否已下发,仅用于调试判定链。 + final bool posterReady; + + /// 首个视频帧确认前实际选择的唯一占位来源。 + final PlayerOpeningFallback fallback; + + /// 当前集是否为相邻集预热槽晋升而来。 + final bool warmSlotReused; + + /// 自动播放中的新集等待 position 首次前进后再移交画面。 + final bool waitForPlaybackFrame; + final String debugSlot; + final int? debugIndex; + final bool showFallbackBackground; final String videoUrl; final bool theater; + final VoidCallback? onVideoVisible; @override State createState() => _PlayerVideoSurfaceState(); } -class _PlayerVideoSurfaceState extends State { - String? _lastDebugState; +class _PlayerVideoSurfaceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _crossFade; + VideoPlayerController? _attachedController; + Widget? _placeholderSnapshot; + Size? _videoSize; + bool _playbackStarted = false; + bool _frameConfirmed = false; + bool _confirmationScheduled = false; + bool _transitioning = false; + bool _videoVisible = false; + bool _videoVisibleNotified = 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; + _logSlot('init', value); + if (_firstFrameConfirmed(value)) { + _confirmFirstFrame(initial: true); + } else { + _logLayer('placeholder', value); + } + } @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) _lastDebugState = null; + 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; + _logSlot('controller-change-after', value); + if (_firstFrameConfirmed(value)) { + _confirmFirstFrame(initial: true); + } else { + _logLayer('placeholder', value); + } + return; + } + if (!_transitioning && + !_videoVisible && + oldWidget.placeholder != widget.placeholder) { + _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); + if (!_frameConfirmed && _firstFrameConfirmed(value)) { + _confirmFirstFrame(); + } else { + _logLayer('placeholder', value); + } + } + } + + @override + void dispose() { + _logSlot('dispose', widget.controller?.value); + _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 reveal = _shouldReveal(value, handoff != null); - _logStateChange(value); + 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: [ - const ColoredBox( - key: Key('player-fallback-background'), - color: AppColors.mediaBlack, - ), - AnimatedOpacity( - key: const Key('player-video-opacity'), - opacity: reveal ? 1 : 0, - duration: handoff != null ? Duration.zero : _videoFadeDuration, - curve: Curves.easeOut, - 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 (widget.showFallbackBackground) + const ColoredBox( + key: Key('player-fallback-background'), + color: AppColors.mediaBlack, + ), + if (showPlaceholder) + KeyedSubtree( + key: const Key('player-single-placeholder'), + child: _placeholderSnapshot!, + ), + 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( @@ -98,78 +198,242 @@ class _PlayerVideoSurfaceState extends State { ); } - bool _shouldReveal(VideoPlayerValue? value, bool covered) { - 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; + void _attachController(VideoPlayerController? controller) { + _attachedController = controller; + controller?.addListener(_handleControllerChanged); } - /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 - VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { - final handoff = widget.handoff; - if (handoff == null || identical(handoff, controller)) return null; - return handoff; + void _detachController(VideoPlayerController? controller) { + controller?.removeListener(_handleControllerChanged); + if (_attachedController == controller) _attachedController = null; } - void _logStateChange(VideoPlayerValue? value) { + 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(() {}); + } + } + + void _updatePlaybackFlags(VideoPlayerValue? value) { + _playbackStarted = _playbackStarted || value?.isPlaying == true; + } + + void _scheduleFirstFrameConfirmation() { + if (_confirmationScheduled || _frameConfirmed) return; + _confirmationScheduled = true; + if (kDebugMode) { + debugPrint( + '[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().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' + '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; + _logSlot('firstFrameRendered', widget.controller?.value); + 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); + _notifyVideoVisible(); + 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); + _notifyVideoVisible(); + } + + void _resetForControllerChange() { + _crossFade.reset(); + _placeholderSnapshot = widget.placeholder; + _videoSize = null; + _playbackStarted = false; + _frameConfirmed = false; + _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 status = _debugStatus(value); + final fallback = phase == 'video' || phase == 'fade-end' + ? 'video' + : _fallbackLabel(widget.fallback); final signature = - '$status|${value?.isInitialized ?? false}|' - '${value?.errorDescription ?? 'none'}'; - if (signature == _lastDebugState) return; - _lastDebugState = signature; - final source = widget.controller?.dataSource ?? widget.videoUrl; + 'slot=${widget.debugSlot} ' + 'index=${widget.debugIndex ?? '-'} ' + '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} ' + 'position=${value?.position.inMilliseconds ?? 0} ' + 'playing=${value?.isPlaying == true ? 1 : 0} ' + 'buffering=${value?.isBuffering == true ? 1 : 0} ' + 'placeholder=${_placeholderLabel()}'; + if (signature == _lastLayerState) return; + _lastLayerState = signature; debugPrint( - '[video] init=${value?.isInitialized ?? false} ' - 'err=${value?.errorDescription ?? 'none'} host=${_host(source)}', + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} $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'; + void _logSlot(String phase, VideoPlayerValue? value) { + if (!kDebugMode) return; + debugPrint( + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' + 'phase=$phase state=${identityHashCode(this)} ' + 'controller=${widget.controller == null ? '-' : identityHashCode(widget.controller)} ' + 'init=${value?.isInitialized == true ? 1 : 0}', + ); } - 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'; + 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) { + PlayerOpeningFallback.handoff => 'handoff', + PlayerOpeningFallback.poster => 'poster', + PlayerOpeningFallback.thumbnails => 'thumbnails-first-frame', + PlayerOpeningFallback.black => 'black', + }; } } -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/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index 266d2db..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( @@ -256,26 +251,33 @@ 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 +304,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/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 0f91a2d..7e566f9 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -41,8 +41,8 @@ class SeekProgressBar extends StatefulWidget { this.thumbnailService, this.spriteAxisService, this.episodeId, - this.posterUrl = '', this.thumbnailsUrl, + this.fallbackDuration = Duration.zero, }); final ValueListenable? valueListenable; @@ -57,8 +57,8 @@ class SeekProgressBar extends StatefulWidget { final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; - final String posterUrl; final String? thumbnailsUrl; + final Duration fallbackDuration; @override State createState() => _SeekProgressBarState(); @@ -222,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 = @@ -236,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 && @@ -250,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, @@ -351,9 +369,8 @@ class _SeekProgressBarState extends State { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, - posterUrl: widget.posterUrl, position: previewPosition, - duration: video.duration, + duration: duration, ), ), ], 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/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'; diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 582494b..bf0dbdc 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -91,20 +91,126 @@ 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_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')); + 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, + ); + expect(session.userWantsPlay, isTrue); + await session.close(); + }); + + test('should_auto_play_previous_after_switching_from_paused_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, + ); + session.setUserWantsPlay(false); + await session.playbackSettled; + expect(session.userWantsPlay, isFalse); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + expect(session.userWantsPlay, isTrue); + expect( + factory.players.firstWhere((player) => player.id == 'ep-1').playing, + isTrue, + ); + 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 +230,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 +255,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/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', + ); }); } diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index d9905c2..a62f69c 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,6 +32,154 @@ 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( + 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/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index d4a2f02..f584ef6 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', ( @@ -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..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 { @@ -114,6 +133,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); @@ -173,19 +218,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, + ); }); } @@ -196,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 { @@ -211,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/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index a977bbc..ce253a4 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -6,14 +6,17 @@ 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_heart_overlay_scope.dart'; +import 'package:fruitshorts/pages/player/widgets/player_tap_gesture_region.dart'; import '../support/fake_catalog.dart'; 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>(); @@ -45,7 +48,6 @@ void main() { ), findsOneWidget, ); - await tester.pumpAndSettle(); } }); @@ -74,6 +76,200 @@ 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 _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); + + 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); + }); + + testWidgets('parent rebuild 30 times keeps the heart burst 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); + + for (var index = 0; index < 30; index++) { + key.currentState!.rebuild(); + clock.advance(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + } + + expect(tester.state(gestureFinder), same(gestureState)); + expect(tester.state(burstFinder), same(burstState)); + expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); + expect(burstFinder, findsNothing); + }); + + testWidgets('heart burst starts at tapped location', (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)); + + expect(find.byType(PlayerDoubleTapHeartBurst), findsOneWidget); + expect( + (tester.getCenter(find.byKey(const Key('double-tap-heart-0'))) - + tapPosition) + .distance, + lessThanOrEqualTo(1), + ); + }); + + 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 clock = _TestClock(); + final key = GlobalKey<_RemovableGestureHarnessState>(); + await tester.pumpWidget( + 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 _pumpTestClock(tester, clock, 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; + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); + elapsedMs += 1600; + 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 { + 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( @@ -122,6 +318,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; @@ -177,6 +375,58 @@ class _HarnessState extends State<_Harness> { } } +class _RemovableGestureHarness extends StatefulWidget { + const _RemovableGestureHarness({super.key, required this.clock}); + + final _TestClock clock; + + @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, + now: widget.clock.now, + onSingleTap: () {}, + onDoubleTapLike: (_) {}, + onLongPressStart: (_, _) {}, + onInteractionEnd: () {}, + ), + ), + ], + ), + ), + ); + } +} + class _TestClock { DateTime _now = DateTime(2026, 10, 22, 12); @@ -186,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 420e3f4..dbd3de9 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -5,20 +5,122 @@ 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_three_develop_hearts_when_burst_starts', ( tester, ) async { + final clock = _HeartClock(); await tester.pumpWidget( - const MaterialApp( - home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + MaterialApp( + home: Scaffold( + body: PlayerDoubleTapHeartBurst( + burstId: 1, + now: clock.now, + onCompleted: _noop, + ), + ), ), ); - await tester.pump(const Duration(milliseconds: 160)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 200)); 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( + find.descendant( + of: find.byType(PlayerDoubleTapHeartBurst), + matching: find.byType(Stack), + ), + ) + .every((stack) => stack.clipBehavior == Clip.none), + isTrue, + ); + }); + + testWidgets('heart burst keeps opacity then fades after 55 percent', ( + tester, + ) async { + final clock = _HeartClock(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlayerDoubleTapHeartBurst( + burstId: 2, + now: clock.now, + onCompleted: _noop, + ), + ), + ), + ); + + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 450)); + expect(_opacity(tester, 0), closeTo(1, 1e-9)); + + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 750)); + expect(_opacity(tester, 0), lessThan(0.5)); + }); + + testWidgets('heart position is anchored to the overlay origin', ( + tester, + ) async { + const origin = Offset(300, 546); + final clock = _HeartClock(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: PlayerDoubleTapHeartBurst( + burstId: 4, + origin: origin, + now: clock.now, + onCompleted: _noop, + ), + ), + ], + ), + ), + ), + ); + + final firstHeart = find.byKey(const Key('double-tap-heart-0')); + expect( + (tester.getCenter(firstHeart) - origin).distance, + lessThanOrEqualTo(1), + ); + + 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)); + }); + + testWidgets('develop heart burst completes after about 1590ms', ( + tester, + ) async { + var completed = false; + final clock = _HeartClock(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlayerDoubleTapHeartBurst( + burstId: 3, + now: clock.now, + onCompleted: () => completed = true, + ), + ), + ), + ); + + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 1600)); + + expect(completed, isTrue); + expect(clock.elapsedMs, greaterThanOrEqualTo(1590)); + expect(clock.elapsedMs, lessThanOrEqualTo(1600)); }); } @@ -26,4 +128,42 @@ 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() {} + +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 6eb8625..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,12 +17,17 @@ 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'; 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_video_surface.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'; @@ -50,7 +56,24 @@ 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(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); }); @@ -65,6 +88,347 @@ void main() { _expectPlayerNavigationBar(tester); }); + testWidgets('should_keep_feed_session_unchanged_when_switching_episode', ( + tester, + ) async { + 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(); + + 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 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(sourceLiveBefore, greaterThan(1)); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + final fullPlayer = _fullPlayerFinder(); + 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); + + 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(), + matching: find.byWidgetPredicate( + (widget) => + widget is PageView && widget.scrollDirection == Axis.vertical, + ), + ) + .first; + for (var index = 0; index < 4; index++) { + 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, + ); + 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.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-back')).hitTestable()); + await tester.pumpAndSettle(); + await sourceLogic.session.preloadSettled; + await tester.pumpAndSettle(); + + final currentSurface = find.descendant( + of: recommendPlayer, + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + 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, 0); + expect(feedPlayer.playCalls, greaterThan(feedPlayCalls)); + expect(playback.requests.length, requestsBeforeReturn); + 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, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, + ); + expect( + tester.widget(currentSurface).controller, + 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', ( + 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(_fullPlayerFinder(), findsOneWidget); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + 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(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + }); + + testWidgets('should_show_non_blocking_loading_when_opening_series', ( + tester, + ) async { + final initialization = Completer(); + final routeFactory = _RouteVideoFactory(); + await _pumpApp( + tester, + playerFactory: (episode) => episode.id == 'the-signal-demo-2' + ? _GatedVideoPlayer(episode, 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 tester.pump(); + 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, + matching: find.byWidgetPredicate( + (widget) => + widget is PageView && widget.scrollDirection == Axis.vertical, + ), + ) + .first; + expect(loadingOverlay, findsOneWidget); + expect(tester.widget(loadingOverlay).ignoring, isTrue); + expect(tester.getSize(loadingOverlay), const Size(40, 40)); + expect( + tester + .widget( + find.descendant( + of: loadingOverlay, + matching: find.byType(AnimatedOpacity), + ), + ) + .duration, + const Duration(milliseconds: 140), + ); + 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, + ); + 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, + ); + + 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(const Duration(milliseconds: 350)); + 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)); + expect(tester.state(fullCurrentSurface), same(fullCurrentSurfaceState)); + + // 切集完成后返回按钮仍可正常点击。 + 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 { + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); + 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 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 fullLogic = _pageLogic(tester, _fullPlayerFinder()); + 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', ( tester, ) async { @@ -270,7 +634,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 @@ -293,6 +657,146 @@ void main() { expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); + 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 = + 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 _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); + final interactionController = Provider.of( + tester.element(gesture), + listen: false, + ); + expect(logic.session.userWantsPlay, isTrue); + + await tester.tapAt(position); + await tester.tapAt(position); + await tester.pump(); + await tester.pump(); + + 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); + } + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('player-rail-like')), + matching: find.byType(Image), + ), + ) + .color, + 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); + }); + + 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: 300)); + await _waitForBufferedHeartFrames(tester); + + 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 { @@ -304,7 +808,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 @@ -348,13 +852,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'))); @@ -769,10 +1273,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'), @@ -795,7 +1296,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); @@ -812,10 +1315,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)); @@ -1027,6 +1535,16 @@ 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, + posterUrl: '/files/locked-first-frame.jpg', + thumbnailsUrl: '/files/locked-thumbnails.vtt', + ), + ), ); await _openSeriesPlayer( tester, @@ -1036,6 +1554,11 @@ 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-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( tester .widget(find.byKey(const Key('player-progress'))) @@ -1268,8 +1791,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', @@ -1282,6 +1807,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(); @@ -1353,6 +1884,33 @@ 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 _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)), + ); + 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'), @@ -1408,6 +1966,94 @@ Future _pumpApp( if (settle) await tester.pumpAndSettle(); } +PlayerLogic _pageLogic(WidgetTester tester, Finder page) { + final dynamic state = tester.state(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 = []; + + PlayerPort create(Episode episode) { + final player = _RouteVideoPlayer(episode); + created.add(player); + episodes.add(episode); + return player; + } +} + +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( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 5), + isInitialized: true, + isPlaying: true, + ); + } + + @override + Future play() async { + playCalls++; + 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, + ); + } + + @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 { bool playing = false; @@ -1457,6 +2103,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', @@ -1472,6 +2127,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_hearts_pixels_test.dart b/test/player/player_hearts_pixels_test.dart new file mode 100644 index 0000000..1aefe69 --- /dev/null +++ b/test/player/player_hearts_pixels_test.dart @@ -0,0 +1,106 @@ +// 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); + final clock = _HeartClock(); + await tester.pumpWidget( + MaterialApp( + 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++, + ), + ), + ], + ), + ), + ), + ), + ); + + 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)); + + 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 _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); + } +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 4aadab2..aa7a3f2 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 {}, ); @@ -357,6 +393,71 @@ 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); + }); + + 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(); @@ -581,6 +682,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'); @@ -594,8 +777,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); @@ -1402,3 +1586,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_opening_frame_test.dart b/test/player/player_opening_frame_test.dart new file mode 100644 index 0000000..c0a33c5 --- /dev/null +++ b/test/player/player_opening_frame_test.dart @@ -0,0 +1,202 @@ +// 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/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 + +00:00:00.000 --> 00:00:01.000 +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_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 { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + + expect(playerOpeningFrame(null, spriteSource: service), isNull); + expect(playerOpeningFrame(' ', spriteSource: service), isNull); + }); + + testWidgets('should_render_prewarmed_zero_frame_from_shared_cache', ( + tester, + ) async { + final loader = _FakeSpriteLoader(); + final service = SpriteThumbnailAxisService(loader: loader); + addTearDown(service.dispose); + const url = 'https://cdn.test/thumbnails.vtt'; + await service.prepareAt(url, Duration.zero); + + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame(thumbnailsUrl: url, spriteSource: service), + ), + ); + await tester.pump(); + + expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); + expect(loader.vttCalls, 1); + expect(loader.spriteCalls, ['sprite_001.jpg']); + }); + + 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', + spriteSource: service, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); + }); + + 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: url, + spriteSource: service, + fit: BoxFit.contain, + ), + ), + ); + + final preview = tester.widget( + find.byKey(const Key('player-opening-sprite')), + ); + expect(preview.fit, BoxFit.contain); + }); +} + +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_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; +} diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 6c647f2..2b3ba99 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -7,118 +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-fallback-cover')), findsNothing); - expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); - expect(_opacity(tester), 0); - expect(find.byKey(const Key('player-debug-video')), findsNothing); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsNothing); }); - testWidgets('should_fade_video_in_within_200ms_after_first_frame', ( + testWidgets('should_show_exactly_one_placeholder_before_first_frame', ( tester, ) async { - final controller = await _pumpSurface(tester); - expect(_opacity(tester), 0); + await _pumpSurface(tester, placeholder: _placeholder('opening')); - controller.value = const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration(milliseconds: 1), - isInitialized: true, - isPlaying: true, - ); - await tester.pump(); - - expect(_opacity(tester), 1); - expect( - tester - .widget( - find.byKey(const Key('player-video-opacity')), - ) - .duration, - const Duration(milliseconds: 200), - ); - await tester.pump(const Duration(milliseconds: 100)); - final halfway = _fadeValue(tester); - expect(halfway, greaterThan(0)); - 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); + 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_show_video_error_above_retained_fallback', ( - tester, - ) async { + testWidgets('should_skip_placeholder_for_warm_controller', (tester) async { await _pumpSurface( tester, + placeholder: _placeholder('opening'), + warmFrameAvailable: true, + thumbnailReady: true, + value: _initializedValue, + ); + + 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_cross_fade_once_after_first_frame_is_confirmed', ( + tester, + ) async { + final controller = await _pumpSurface( + tester, + placeholder: _placeholder('opening'), + thumbnailReady: true, + waitForPlaybackFrame: true, + ); + controller.value = _readyValue(position: Duration.zero, playing: true); + await tester.pump(); + + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + + controller.value = _readyValue( + position: const Duration(milliseconds: 1), + playing: true, + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + 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_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(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', ( - 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, - ), - ), - ), - ); - } - - await pump(previous); - expect(find.byKey(const Key('player-video-handoff')), findsOneWidget); - 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? 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'), @@ -132,6 +144,10 @@ Future _pumpSurface( controller: controller, videoUrl: controller.dataSource, theater: false, + placeholder: placeholder, + warmFrameAvailable: warmFrameAvailable, + thumbnailReady: thumbnailReady, + waitForPlaybackFrame: waitForPlaybackFrame, ), ), ), @@ -146,16 +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; -} - -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; + .widget(find.byKey(const Key('player-video-crossfade'))) + .opacity + .value; } 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); + }); +} 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), ), diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 5868e65..c39412e 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -464,28 +464,36 @@ 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).last, findsOneWidget); expect( tester.widget(find.byType(PlayerPage).last).homeMode, isFalse, ); - await tester.binding.handlePopRoute(); + expect(find.byKey(const Key('player-back')), findsOneWidget); + 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); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); + expect(find.byType(PlayerPage).last, findsOneWidget); expect( tester.widget(find.byType(PlayerPage).last).homeMode, isFalse, ); + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); }); testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', ( 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,