From 0cb0ab0613a7d3617c27546858e3785273138c52 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:39:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E9=87=8D=E6=9E=84=E5=8D=95?= =?UTF-8?q?=E5=B1=82=E8=BF=87=E6=B8=A1=E5=B9=B6=E9=98=BB=E6=AD=A2=E9=94=81?= =?UTF-8?q?=E5=AE=9A=E9=9B=86=E4=B8=B2=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/playback_session_controls.dart | 20 + lib/pages/player/player_logic.dart | 31 +- lib/pages/player/player_view.dart | 67 +-- .../player/widgets/player_video_surface.dart | 412 +++++++++++------- test/playback/playback_session_test.dart | 86 ++++ test/player/player_fullscreen_test.dart | 11 + test/player/player_logic_test.dart | 138 ++++++ test/player/player_video_surface_test.dart | 227 ++++------ 8 files changed, 657 insertions(+), 335 deletions(-) diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index e0a4045..293f070 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -10,6 +10,26 @@ extension PlaybackSessionControls on PlaybackSession { _warmNeighbors(this, preferredNext: forward); } + /// 选中锁定集时立即停播并释放所有原生播放器,不创建新片源。 + Future selectLocked( + int index, { + int? preloadIndex, + int? previousIndex, + }) { + if (_closed || index < 0 || index >= episodes.length) { + return Future.value(); + } + _generation++; + _selected = false; + _currentIndex = index; + _rememberNeighbors(preloadIndex, previousIndex); + _loading = false; + _error = null; + _userWantsPlay = false; + _notify(); + return _releasePlayers(); + } + /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 PlayerPort? handoffAt(int index) { final slot = _handoff; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 07b450a..a638617 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -100,6 +100,7 @@ class PlayerLogic extends ChangeNotifier { bool pip = false; bool pipPreparing = false; final Map pickedEpisode = {}; + final Set _unlockedEpisodeIds = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; @@ -238,7 +239,8 @@ class PlayerLogic extends ChangeNotifier { episodes: sessionEpisodes, factory: factory, // 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定 - canPlay: (episode) => episode.videoUrl.isNotEmpty, + canPlay: (episode) => + episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode), prepare: preparePlayer, activate: activatePlayer, coordinator: coordinator, @@ -428,6 +430,7 @@ class PlayerLogic extends ChangeNotifier { final localPath = await downloads?.findLocalPath(episode.id); if (request != pageRequest) return false; if (localPath != null) { + _unlockedEpisodeIds.add(episode.id); blockedTicket = null; blockedEpisodeId = null; qualities = const []; @@ -452,6 +455,7 @@ class PlayerLogic extends ChangeNotifier { final playUrl = preferred ?? ticket.playUrl; if (preferred == null) selectedQuality = 'AUTO'; if (ticket.accessible && playUrl.isNotEmpty) { + _unlockedEpisodeIds.add(episode.id); // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl( playUrl, @@ -467,7 +471,7 @@ class PlayerLogic extends ChangeNotifier { } catch (exception) { if (request != pageRequest) return false; // 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了 - if (episode.videoUrl.isNotEmpty) { + if (episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode)) { ticketError = null; return true; } @@ -566,6 +570,7 @@ class PlayerLogic extends ChangeNotifier { unlockError = service.unlockError; return false; } + _unlockedEpisodeIds.add(episode.id); final ready = await ensureTicket(index, forceRefresh: true); if (ready) { await _selectVisible(index); @@ -962,6 +967,22 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } + final episode = sessionEpisodes[index]; + if (isEpisodeLocked(episode)) { + blockedTicket = null; + blockedEpisodeId = episode.id; + ticketError = null; + await session.selectLocked( + index, + preloadIndex: nextVisibleIndex(index), + previousIndex: previousVisibleIndex(index), + ); + if (request != pageRequest || + feedRevisionAtStart != feedRevision || + !isMounted()) { + return; + } + } // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) final ready = await ensureTicket(index); if (request != pageRequest || @@ -973,6 +994,12 @@ class PlayerLogic extends ChangeNotifier { await _selectVisible(index); } + /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 + bool isEpisodeLocked(Episode episode) { + return (episode.locked || !episode.isFree) && + !_unlockedEpisodeIds.contains(episode.id); + } + /// 按可见队列选择一集,并预热上一集与下一集。 Future _selectVisible(int index) { return session.select( diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 90958a2..02d56ce 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -146,9 +146,8 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); - _spriteAxisService = SpriteThumbnailAxisService( - spriteCacheCapacity: 8, - )..addListener(_handleSpriteFrameChanged); + _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) + ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -389,10 +388,9 @@ class _PlayerPageState extends State continue; } final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl; - if (thumbnailsUrl != null) { - unawaited( - _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), - ); + if (thumbnailsUrl != null && + !logic.isEpisodeLocked(logic.sessionEpisodes[index])) { + unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } } } @@ -603,33 +601,36 @@ class _PlayerPageState extends State ) : displayed; final selected = index == logic.session.currentIndex; + final locked = + logic.isEpisodeLocked(episode) || + logic.session.isLocked || + logic.blockedEpisodeId == episode.id; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. - final port = - (selected ? logic.session.active : null) ?? - logic.session.preparedPlayerAt(index); + final port = locked + ? null + : (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; - final handoff = selected ? logic.session.handoffAt(index) : null; + final handoff = selected && !locked ? logic.session.handoffAt(index) : null; final handoffPort = handoff is VideoPlayerPort ? handoff : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; if (selected && videoReady) _logSwipeReveal(); - final ready = videoReady || port != null; + final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = - selected && logic.session.reusedWarmAt(index); + selected && !locked && logic.session.reusedWarmAt(index); final thumbnailsUrl = episode.thumbnailsUrl; final openingFrameReady = thumbnailsUrl != null && _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; if (thumbnailsUrl != null) { - unawaited( - _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), - ); + unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } - final locked = logic.session.isLocked || episode.locked; if (selected && + !locked && videoPort != null && videoPort.controller.value.isInitialized) { unawaited( @@ -649,20 +650,27 @@ class _PlayerPageState extends State color: AppColors.mediaBlack, ), PlayerVideoSurface( - controller: videoPort?.controller, - handoff: handoffPort?.controller, + controller: locked ? null : videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - holdOpeningUntilFirstFrame: logic.session.userWantsPlay, - preparedFrameAvailable: preparedFrameAvailable, - openingFrameReady: openingFrameReady, - openingFrame: playerOpeningFrame( - thumbnailsUrl, - spriteSource: _spriteAxisService, - fit: videoFit, - ), + warmFrameAvailable: preparedFrameAvailable, + thumbnailReady: openingFrameReady, + waitForPlaybackFrame: logic.session.userWantsPlay, + placeholder: locked + ? null + : handoffPort != null + ? ControllerFramePlaceholder( + controller: handoffPort.controller, + theater: theater, + ) + : playerOpeningFrame( + thumbnailsUrl, + spriteSource: _spriteAxisService, + fit: videoFit, + ), ), if (selected && + !locked && !theater && !ready && !logic.ticketLoading && @@ -884,10 +892,7 @@ class _PlayerPageState extends State controlsVisible: _chromeVisible, ), // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 - if (selected && - !theater && - !ready && - logic.blockedEpisodeId == episode.id) + if (selected && !theater && locked) PlayerBlockedNotice( l10n: l10n, episode: episode, diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 9392121..2aebe3a 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -6,37 +6,36 @@ import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; +const Duration _crossFadeDuration = Duration(milliseconds: 180); const double _errorHorizontal = 32; const int _errorMaxLines = 3; -/// 黑色底层与视频层共用同一控制器。清晰度切换时用仍在播放的旧画面盖住新纹理的黑帧。 +/// 播放区只维护一个占位和一个视频层,首帧确认后执行一次交叉淡化。 class PlayerVideoSurface extends StatefulWidget { const PlayerVideoSurface({ super.key, required this.controller, required this.videoUrl, required this.theater, - this.handoff, - this.openingFrame, - this.openingFrameReady = false, - this.holdOpeningUntilFirstFrame = false, - this.preparedFrameAvailable = false, + this.placeholder, + this.warmFrameAvailable = false, + this.thumbnailReady = false, + this.waitForPlaybackFrame = false, }); final VideoPlayerController? controller; - final VideoPlayerController? handoff; - /// 解码出第一帧之前垫上的视频首帧,没有则为空。 - final Widget? openingFrame; + /// 视频首帧未确认时唯一的占位 widget。 + final Widget? placeholder; - /// 当前集雪碧图 t=0 是否已在共享缓存中可用。 - final bool openingFrameReady; + /// 相邻集预热槽已可用时直接出视频,不挂载占位。 + final bool warmFrameAvailable; - /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 - final bool holdOpeningUntilFirstFrame; + /// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。 + final bool thumbnailReady; - /// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。 - final bool preparedFrameAvailable; + /// 自动播放中的新集等待 position 首次前进后再移交画面。 + final bool waitForPlaybackFrame; final String videoUrl; final bool theater; @@ -44,53 +43,87 @@ class PlayerVideoSurface extends StatefulWidget { State createState() => _PlayerVideoSurfaceState(); } -class _PlayerVideoSurfaceState extends State { - String? _lastDebugState; - String? _lastLayerState; - VideoPlayerController? _firstFrameController; +class _PlayerVideoSurfaceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _crossFade; + VideoPlayerController? _attachedController; + Widget? _placeholderSnapshot; + Size? _videoSize; bool _playbackStarted = false; - bool _firstFrameSeen = false; + bool _frameConfirmed = false; + bool _confirmationScheduled = false; + bool _transitioning = false; + bool _videoVisible = false; + String? _lastLayerState; + + @override + void initState() { + super.initState(); + _crossFade = AnimationController(vsync: this, duration: _crossFadeDuration) + ..addStatusListener(_handleFadeStatus); + _placeholderSnapshot = widget.placeholder; + _attachController(widget.controller); + final value = widget.controller?.value; + _updatePlaybackFlags(value); + _videoSize = value != null && value.isInitialized ? value.size : null; + if (_firstFrameConfirmed(value)) { + _confirmFirstFrame(initial: true); + } + } @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { - _lastDebugState = null; - _lastLayerState = null; - _firstFrameController = null; - _playbackStarted = false; - _firstFrameSeen = false; + _detachController(oldWidget.controller); + _resetForControllerChange(); + _attachController(widget.controller); + final value = widget.controller?.value; + _updatePlaybackFlags(value); + _videoSize = value != null && value.isInitialized ? value.size : null; + if (_firstFrameConfirmed(value)) _confirmFirstFrame(initial: true); + return; } + if (!_transitioning && + !_videoVisible && + oldWidget.placeholder != widget.placeholder) { + _placeholderSnapshot = widget.placeholder; + } + if (oldWidget.thumbnailReady != widget.thumbnailReady || + oldWidget.warmFrameAvailable != widget.warmFrameAvailable) { + final value = widget.controller?.value; + _updatePlaybackFlags(value); + if (!_frameConfirmed && _firstFrameConfirmed(value)) { + _confirmFirstFrame(); + } else { + _logLayer('placeholder', value); + } + } + } + + @override + void dispose() { + _detachController(widget.controller); + _crossFade + ..removeStatusListener(_handleFadeStatus) + ..dispose(); + super.dispose(); } @override Widget build(BuildContext context) { final controller = widget.controller; - if (controller == null) return _frame(null); - return ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) => _frame(value), - ); - } - - Widget _frame(VideoPlayerValue? value) { - final controller = widget.controller; - final handoff = _liveHandoff(controller); - final firstFrameSeen = _trackFrameState(controller, value); - final firstFrameReady = widget.preparedFrameAvailable || firstFrameSeen; - final textureReady = _shouldReveal(value, handoff != null); - final reveal = - textureReady && - (!widget.holdOpeningUntilFirstFrame || - widget.preparedFrameAvailable || - firstFrameReady || - _playbackStarted && - value?.isPlaying == false && - value?.isBuffering != true); - final showOpening = - widget.openingFrame != null && handoff == null && !reveal; - _logStateChange(value); - _logLayerState(value, handoff, reveal, firstFrameReady, showOpening); + final value = controller?.value; + final videoSize = _videoSize; + final showVideo = controller != null && videoSize != null; + final showPlaceholder = !_videoVisible && _placeholderSnapshot != null; + final video = showVideo + ? _VideoLayer( + controller: controller, + size: videoSize, + theater: widget.theater, + ) + : null; return Stack( fit: StackFit.expand, children: [ @@ -98,29 +131,18 @@ class _PlayerVideoSurfaceState extends State { key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), - if (showOpening) + if (showPlaceholder) KeyedSubtree( - key: const Key('player-opening-frame'), - child: widget.openingFrame!, + key: const Key('player-single-placeholder'), + child: _placeholderSnapshot!, ), - AnimatedOpacity( - key: const Key('player-video-opacity'), - opacity: reveal ? 1 : 0, - duration: Duration.zero, - child: reveal && controller != null - ? _VideoLayer( - controller: controller, - value: value!, - theater: widget.theater, - ) - : const SizedBox.expand(), - ), - if (handoff != null) - _VideoLayer( - key: const Key('player-video-handoff'), - controller: handoff, - value: handoff.value, - theater: widget.theater, + if (_videoVisible && video != null) + KeyedSubtree(key: const Key('player-video-layer'), child: video) + else if (_transitioning && video != null) + FadeTransition( + key: const Key('player-video-crossfade'), + opacity: _crossFade, + child: video, ), if (kDebugMode && value?.hasError == true) Center( @@ -140,134 +162,194 @@ class _PlayerVideoSurfaceState extends State { ); } - bool _shouldReveal(VideoPlayerValue? value, bool covered) { - if (covered) return value?.isInitialized ?? false; - if (value == null || value.hasError || !value.isInitialized) return false; - return value.size.width > 0 && value.size.height > 0; + void _attachController(VideoPlayerController? controller) { + _attachedController = controller; + controller?.addListener(_handleControllerChanged); } - bool _trackFrameState( - VideoPlayerController? controller, - VideoPlayerValue? value, - ) { - if (!identical(_firstFrameController, controller)) { - _firstFrameController = controller; - _playbackStarted = false; - _firstFrameSeen = false; + void _detachController(VideoPlayerController? controller) { + controller?.removeListener(_handleControllerChanged); + if (_attachedController == controller) _attachedController = null; + } + + void _handleControllerChanged() { + final value = widget.controller?.value; + final nextSize = value != null && value.isInitialized ? value.size : null; + final sizeChanged = nextSize != _videoSize; + _updatePlaybackFlags(value); + _videoSize = nextSize; + if (!_frameConfirmed && _firstFrameConfirmed(value)) { + _scheduleFirstFrameConfirmation(); + } else if (sizeChanged && mounted) { + setState(() {}); } - if (value == null) return false; - _playbackStarted = _playbackStarted || value.isPlaying; - _firstFrameSeen = - _firstFrameSeen || value.position > Duration.zero || value.isCompleted; - return _firstFrameSeen; } - /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 - VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { - final handoff = widget.handoff; - if (handoff == null || identical(handoff, controller)) return null; - return handoff; + void _updatePlaybackFlags(VideoPlayerValue? value) { + _playbackStarted = _playbackStarted || value?.isPlaying == true; } - void _logStateChange(VideoPlayerValue? value) { + void _scheduleFirstFrameConfirmation() { + if (_confirmationScheduled || _frameConfirmed) return; + _confirmationScheduled = true; + if (kDebugMode) { + debugPrint( + '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + 'phase=first-frame-scheduled', + ); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (kDebugMode) { + debugPrint( + '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + 'phase=first-frame-callback mounted=$mounted', + ); + } + _confirmationScheduled = false; + if (!mounted || _frameConfirmed) return; + if (_firstFrameConfirmed(widget.controller?.value)) { + _confirmFirstFrame(); + } + }); + WidgetsBinding.instance.scheduleFrame(); + } + + bool _firstFrameConfirmed(VideoPlayerValue? value) { + if (value == null || + !value.isInitialized || + value.hasError || + value.size.width <= 0 || + value.size.height <= 0) { + return false; + } + if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true; + if (value.position > Duration.zero || value.isCompleted) return true; + return _playbackStarted && !value.isPlaying && !value.isBuffering; + } + + void _confirmFirstFrame({bool initial = false}) { + if (_frameConfirmed || _videoSize == null) return; + _frameConfirmed = true; + final canSkipPlaceholder = + widget.warmFrameAvailable || _placeholderSnapshot == null; + if (canSkipPlaceholder) { + if (initial) { + _videoVisible = true; + _placeholderSnapshot = null; + } else { + setState(() { + _videoVisible = true; + _placeholderSnapshot = null; + }); + } + _logLayer('video', widget.controller?.value); + return; + } + if (initial) { + _transitioning = true; + _logLayer('fade-start', widget.controller?.value); + _crossFade.forward(from: 0); + } else { + setState(() { + _transitioning = true; + _placeholderSnapshot = widget.placeholder; + }); + _logLayer('fade-start', widget.controller?.value); + _crossFade.forward(from: 0); + } + } + + void _handleFadeStatus(AnimationStatus status) { + if (status != AnimationStatus.completed || !mounted) return; + setState(() { + _transitioning = false; + _videoVisible = true; + _placeholderSnapshot = null; + }); + _logLayer('fade-end', widget.controller?.value); + } + + void _resetForControllerChange() { + _crossFade.reset(); + _placeholderSnapshot = widget.placeholder; + _videoSize = null; + _playbackStarted = false; + _frameConfirmed = false; + _confirmationScheduled = false; + _transitioning = false; + _videoVisible = false; + _lastLayerState = null; + } + + void _logLayer(String phase, VideoPlayerValue? value) { if (!kDebugMode) return; - final status = _debugStatus(value); - final signature = - '$status|${value?.isInitialized ?? false}|' - '${value?.errorDescription ?? 'none'}'; - if (signature == _lastDebugState) return; - _lastDebugState = signature; - final source = widget.controller?.dataSource ?? widget.videoUrl; - debugPrint( - '[video] init=${value?.isInitialized ?? false} ' - 'err=${value?.errorDescription ?? 'none'} host=${_host(source)}', - ); - } - - void _logLayerState( - VideoPlayerValue? value, - VideoPlayerController? handoff, - bool reveal, - bool firstFrameReady, - bool showOpening, - ) { - if (!kDebugMode) return; - final reason = handoff != null - ? 'handoff' - : !_shouldReveal(value, handoff != null) - ? 'uninitialized' - : widget.preparedFrameAvailable - ? 'warm-frame' - : !firstFrameReady && widget.holdOpeningUntilFirstFrame - ? 'awaiting-first-frame' - : 'reveal'; - final fallback = handoff != null - ? 'handoff' - : showOpening - ? widget.openingFrameReady - ? 'thumbnails-first-frame' - : 'black' - : reveal + final fallback = phase == 'video' || phase == 'fade-end' ? 'video' + : _placeholderSnapshot == null + ? 'black' + : widget.thumbnailReady + ? 'thumbnails-first-frame' : 'black'; final signature = - 'warm=${widget.preparedFrameAvailable ? 1 : 0} ' - 'frame=${firstFrameReady ? 1 : 0} ' - 'thumbnails=${widget.openingFrameReady ? 'ready' : 'pending'} ' + 'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} ' + 'frame=${_frameConfirmed ? 1 : 0} ' + 'thumbnails=${widget.thumbnailReady ? 'ready' : 'pending'} ' 'fallback=$fallback ' - 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'init=${value?.isInitialized == true ? 1 : 0} ' + 'position=${value?.position.inMilliseconds ?? 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' - 'buffering=${value?.isBuffering == true ? 1 : 0} ' - 'reason=$reason'; + 'buffering=${value?.isBuffering == true ? 1 : 0}'; if (signature == _lastLayerState) return; _lastLayerState = signature; - debugPrint('[player-layer] $signature'); - } - - String _debugStatus(VideoPlayerValue? value) { - if (value == null) return 'loading'; - if (value.hasError) { - return 'error: ${value.errorDescription ?? 'unknown'}'; - } - if (value.isBuffering) return 'buffering'; - if (value.isInitialized) return 'initialized'; - return 'loading'; - } - - String _host(String source) { - final uri = Uri.tryParse(source); - if (uri == null || uri.host.isEmpty) return 'unknown'; - final port = uri.hasPort - ? uri.port - : uri.scheme == 'https' - ? 443 - : 80; - return '${uri.host}:$port'; + debugPrint( + '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature', + ); } } -class _VideoLayer extends StatelessWidget { - const _VideoLayer({ +/// 将旧 controller 作为唯一占位,不额外叠加 handoff 层。 +class ControllerFramePlaceholder extends StatelessWidget { + const ControllerFramePlaceholder({ super.key, required this.controller, - required this.value, required this.theater, }); final VideoPlayerController controller; - final VideoPlayerValue value; final bool theater; @override Widget build(BuildContext context) { + final size = controller.value.size; return FittedBox( fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( - width: value.size.width, - height: value.size.height, + width: size.width, + height: size.height, child: VideoPlayer(controller, key: ValueKey(controller)), ), ); } } + +class _VideoLayer extends StatelessWidget { + const _VideoLayer({ + required this.controller, + required this.size, + required this.theater, + }); + + final VideoPlayerController controller; + final Size size; + final bool theater; + + @override + Widget build(BuildContext context) => FittedBox( + fit: theater ? BoxFit.contain : BoxFit.cover, + child: SizedBox( + width: size.width, + height: size.height, + child: VideoPlayer(controller, key: ValueKey(controller)), + ), + ); +} diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index d9905c2..7595d08 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,6 +32,92 @@ void main() { await session.close(); }); + test('should_release_previous_player_immediately_when_selecting_locked', () async { + final lockedEpisodes = [ + Episode( + id: 'free', + number: 1, + title: 'Free', + isFree: true, + videoUrl: 'https://example.test/free.mp4', + ), + Episode( + id: 'locked', + number: 2, + title: 'Locked', + isFree: false, + locked: true, + videoUrl: 'https://example.test/should-not-play.mp4', + ), + Episode( + id: 'next', + number: 3, + title: 'Next', + isFree: true, + videoUrl: 'https://example.test/next.mp4', + ), + ]; + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: lockedEpisodes, + factory: factory.create, + canPlay: (episode) => !episode.locked, + ); + await session.select(0, preloadIndex: 1); + final previous = factory.created.single; + + await session.selectLocked(1, preloadIndex: 2, previousIndex: 0); + await session.playbackSettled; + + expect(session.active, isNull); + expect(previous.pauseCalls, greaterThan(0)); + expect(previous.disposed, isTrue); + expect(factory.live, 0); + expect(factory.created, hasLength(1)); + + await session.select(2, previousIndex: 1); + expect(session.active, same(factory.created.last)); + expect(factory.created.last.playCalls, greaterThan(0)); + await session.close(); + }); + + test('should_play_locked_episode_only_after_it_is_unlocked', () async { + final lockedEpisodes = [ + Episode( + id: 'free', + number: 1, + title: 'Free', + isFree: true, + videoUrl: 'https://example.test/free.mp4', + ), + Episode( + id: 'locked', + number: 2, + title: 'Locked', + isFree: false, + locked: true, + videoUrl: 'https://example.test/locked.mp4', + ), + ]; + final unlocked = {}; + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: lockedEpisodes, + factory: factory.create, + canPlay: (episode) => episode.isFree || unlocked.contains(episode.id), + ); + await session.select(0); + await session.selectLocked(1); + expect(session.active, isNull); + + unlocked.add('locked'); + await session.select(1); + + expect(session.active, same(factory.created.last)); + expect(factory.created.last.playCalls, greaterThan(0)); + await session.close(); + }); + test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async { final factory = FakeFactory(holdFirstInitialize: true); final session = PlaybackSession( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b10bc7c..354f4da 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1031,6 +1031,14 @@ void main() { await _pumpApp( tester, catalog: FakeCatalogRepository(dramas: const [_lockedDrama]), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-demo-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), ); await _openSeriesPlayer( tester, @@ -1040,6 +1048,9 @@ void main() { expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsNothing); expect( tester .widget(find.byKey(const Key('player-progress'))) diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index e9498ac..55c4a2b 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -11,12 +11,15 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/models/download_license.dart'; import 'package:fruitshorts/core/data/models/download_policy.dart'; import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; @@ -46,6 +49,37 @@ Drama _drama( ], ); +Drama _gateDrama() => const Drama( + id: 'gate', + title: 'Gate', + synopsis: 'Gate synopsis', + posterUrl: '/files/gate.png', + episodes: [ + Episode( + id: 'gate-1', + number: 1, + title: 'Free', + isFree: true, + videoUrl: 'https://example.test/gate-1.mp4', + ), + Episode( + id: 'gate-2', + number: 2, + title: 'Locked', + isFree: false, + locked: true, + videoUrl: 'https://example.test/gate-2.mp4', + ), + Episode( + id: 'gate-3', + number: 3, + title: 'Next', + isFree: true, + videoUrl: 'https://example.test/gate-3.mp4', + ), + ], +); + AndroidPictureInPicture _pip() => AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip')); @@ -53,11 +87,13 @@ PlayerLogic _logic( FakeWatchHistory history, { PlaybackRepository? playback, PlaybackPositionCache? positionCache, + WalletController? wallet, Future Function()? waitForFrame, }) => PlayerLogic( history: history, playback: playback ?? FakePlaybackRepository(), pictureInPicture: _pip(), + wallet: wallet, positionCache: positionCache, waitForFrame: waitForFrame ?? () async {}, ); @@ -581,6 +617,88 @@ void main() { await _drain(logic); }); + test('should_pause_and_release_when_swiping_to_locked_episode', () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(); + final logic = _logic(FakeWatchHistory(), playback: playback); + logic.attach( + PlayerPage(drama: _gateDrama(), initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + final previous = factory.created.single; + + playback.ticket = const PlayTicket( + episodeId: 'gate-2', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ); + await logic.selectEpisode(1); + await _drain(logic); + + expect(previous.pauses, greaterThan(0)); + expect(previous.disposed, isTrue); + expect(logic.session.active, isNull); + expect(factory.live, 0); + expect(factory.created, hasLength(1)); + expect(logic.blockedEpisodeId, 'gate-2'); + + playback.ticket = null; + await logic.selectEpisode(2); + await _drain(logic); + expect(logic.session.active, isNotNull); + expect(factory.created.last.plays, greaterThan(0)); + + logic.dispose(); + await _drain(logic); + }); + + test('should_start_locked_episode_after_unlock_succeeds', () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(); + final wallet = WalletController(_UnlockCommerceRepository()); + final logic = _logic( + FakeWatchHistory(), + playback: playback, + wallet: wallet, + ); + logic.attach( + PlayerPage(drama: _gateDrama(), initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + playback.ticket = const PlayTicket( + episodeId: 'gate-2', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ); + await logic.selectEpisode(1); + await _drain(logic); + expect(logic.session.active, isNull); + + playback.ticket = const PlayTicket( + episodeId: 'gate-2', + accessible: true, + accessType: PlayTicket.coinRequired, + playUrl: '/files/gate-2.mp4', + ); + final unlocked = await logic.unlockEpisode(1); + await _drain(logic); + + expect(unlocked, isTrue); + expect(logic.isEpisodeLocked(logic.sessionEpisodes[1]), isFalse); + final active = logic.session.active; + expect(active, isA<_RecordingPlayer>()); + expect((active! as _RecordingPlayer).plays, greaterThan(0)); + + logic.dispose(); + await _drain(logic); + }); + test('should_preload_only_what_follows_when_starting_from_a_later_index', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); @@ -1403,3 +1521,23 @@ class _VideoRecordingPlayer extends VideoPlayerPort { await controller.dispose(); } } + +class _UnlockCommerceRepository implements CommerceRepository { + @override + Future fetchWallet() async => const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 100, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ); + + @override + Future unlock(String episodeId) async => const UnlockResult( + unlocked: true, + accessType: PlayTicket.coinRequired, + coinCost: 30, + coinBalance: 70, + ); +} diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 9f70bee..2b3ba99 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -7,179 +7,130 @@ import 'package:video_player/video_player.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; void main() { - testWidgets('should_keep_fallback_visible_before_video_is_ready', ( + testWidgets('should_show_black_without_placeholder_before_video', ( tester, ) async { await _pumpSurface(tester); expect(_fallbackColor(tester), Colors.black); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect(_opacity(tester), 0); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsNothing); }); - testWidgets('should_show_opening_frame_before_decode_is_ready', ( + testWidgets('should_show_exactly_one_placeholder_before_first_frame', ( tester, ) async { + await _pumpSurface(tester, placeholder: _placeholder('opening')); + + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('opening')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsNothing); + }); + + testWidgets('should_skip_placeholder_for_warm_controller', (tester) async { await _pumpSurface( tester, - openingFrame: const ColoredBox( - key: Key('fake-opening'), - color: Colors.red, - ), + placeholder: _placeholder('opening'), + warmFrameAvailable: true, + thumbnailReady: true, + value: _initializedValue, ); - expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); - expect(find.byKey(const Key('fake-opening')), findsOneWidget); - expect(_opacity(tester), 0); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); }); - testWidgets('should_reveal_paused_frame_when_initialized', (tester) async { - final controller = await _pumpSurface(tester); - controller.value = const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration.zero, - isInitialized: true, - isPlaying: false, - ); - await tester.pump(); - - expect(_opacity(tester), 1); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - }); - - testWidgets('should_show_cover_until_first_playback_frame', ( + testWidgets('should_cross_fade_once_after_first_frame_is_confirmed', ( tester, ) async { final controller = await _pumpSurface( tester, - openingFrame: const ColoredBox(color: Colors.red), - holdOpeningUntilFirstFrame: true, - ); - expect(_opacity(tester), 0); - - controller.value = const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration.zero, - isInitialized: true, - isPlaying: true, + placeholder: _placeholder('opening'), + thumbnailReady: true, + waitForPlaybackFrame: true, ); + controller.value = _readyValue(position: Duration.zero, playing: true); await tester.pump(); - expect(_opacity(tester), 0); - expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); - controller.value = const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration(milliseconds: 1), - isInitialized: true, - isPlaying: true, + controller.value = _readyValue( + position: const Duration(milliseconds: 1), + playing: true, ); await tester.pump(); + await tester.pump(); + await tester.pump(); - expect(_opacity(tester), 1); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect( - tester - .widget( - find.byKey(const Key('player-video-opacity')), - ) - .duration, - Duration.zero, + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget); + expect(find.byKey(const Key('player-video-layer')), findsNothing); + expect(_crossFadeValue(tester), 0); + + await tester.pump(const Duration(milliseconds: 90)); + expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget); + expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget); + expect(_crossFadeValue(tester), greaterThan(0)); + expect(_crossFadeValue(tester), lessThan(1)); + + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsOneWidget); + + controller.value = _readyValue( + position: const Duration(seconds: 1), + playing: true, ); + await tester.pump(); + expect(find.byKey(const Key('player-video-crossfade')), findsNothing); + expect(find.byKey(const Key('player-video-layer')), findsOneWidget); }); - testWidgets('should_skip_cover_when_prewarmed_frame_is_available', ( - tester, - ) async { - await _pumpSurface( - tester, - openingFrame: const ColoredBox(color: Colors.red), - holdOpeningUntilFirstFrame: true, - preparedFrameAvailable: true, - value: const VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration.zero, - isInitialized: true, - isPlaying: false, - ), - ); - - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect(_opacity(tester), 1); - }); - - testWidgets('should_show_video_error_above_retained_fallback', ( - tester, - ) async { + testWidgets('should_keep_placeholder_above_video_error', (tester) async { await _pumpSurface( tester, + placeholder: _placeholder('opening'), value: const VideoPlayerValue.erroneous('No route to host'), ); + expect(find.byKey(const Key('opening')), findsOneWidget); expect(find.byKey(const Key('player-video-error')), findsOneWidget); expect(find.text('No route to host'), findsOneWidget); - expect(_fallbackColor(tester), Colors.black); - expect(_opacity(tester), 0); - }); - - testWidgets('should_cover_new_video_only_while_handoff_is_held', ( - tester, - ) async { - final current = VideoPlayerController.networkUrl( - Uri.parse('http://192.168.31.187:8081/files/720.m3u8'), - ); - final previous = VideoPlayerController.networkUrl( - Uri.parse('http://192.168.31.187:8081/files/360.m3u8'), - ); - addTearDown(current.dispose); - addTearDown(previous.dispose); - const ready = VideoPlayerValue( - duration: Duration(minutes: 1), - size: Size(1280, 720), - position: Duration(seconds: 3), - isInitialized: true, - isPlaying: true, - ); - current.value = ready; - previous.value = ready; - - Future pump(VideoPlayerController? handoff) { - return tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: PlayerVideoSurface( - controller: current, - handoff: handoff, - videoUrl: current.dataSource, - theater: false, - openingFrame: const ColoredBox(color: Colors.red), - ), - ), - ), - ); - } - - await pump(previous); - expect(find.byKey(const Key('player-video-handoff')), findsOneWidget); - expect(find.byKey(const Key('player-opening-frame')), findsNothing); - expect(_opacity(tester), 1); - - await pump(null); - expect(find.byKey(const Key('player-video-handoff')), findsNothing); - expect(find.byType(VideoPlayer), findsOneWidget); }); } +Widget _placeholder(String key) => ColoredBox(key: Key(key), color: Colors.red); + +const VideoPlayerValue _initializedValue = VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration.zero, + isInitialized: true, + isPlaying: false, +); + +VideoPlayerValue _readyValue({ + required Duration position, + required bool playing, +}) => VideoPlayerValue( + duration: const Duration(minutes: 1), + size: const Size(1280, 720), + position: position, + isInitialized: true, + isPlaying: playing, +); + Future _pumpSurface( WidgetTester tester, { VideoPlayerValue? value, - Widget? openingFrame, - bool holdOpeningUntilFirstFrame = false, - bool preparedFrameAvailable = false, + Widget? placeholder, + bool warmFrameAvailable = false, + bool thumbnailReady = false, + bool waitForPlaybackFrame = false, }) async { final controller = VideoPlayerController.networkUrl( Uri.parse('http://192.168.31.187:8081/files/video.mp4'), @@ -193,9 +144,10 @@ Future _pumpSurface( controller: controller, videoUrl: controller.dataSource, theater: false, - openingFrame: openingFrame, - holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame, - preparedFrameAvailable: preparedFrameAvailable, + placeholder: placeholder, + warmFrameAvailable: warmFrameAvailable, + thumbnailReady: thumbnailReady, + waitForPlaybackFrame: waitForPlaybackFrame, ), ), ), @@ -210,8 +162,9 @@ Color _fallbackColor(WidgetTester tester) { .color; } -double _opacity(WidgetTester tester) { +double _crossFadeValue(WidgetTester tester) { return tester - .widget(find.byKey(const Key('player-video-opacity'))) - .opacity; + .widget(find.byKey(const Key('player-video-crossfade'))) + .opacity + .value; }