diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f89976c..355bb62 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -142,11 +142,11 @@ class _PlayerPageState extends State bool _presentationActive = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; - int? _loadingIndex; int? _videoReadyIndex; bool _loadingVisible = false; bool _loadingRemoved = true; bool _loadingFadeScheduled = false; + bool _loggedLoadingUiVisible = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -179,10 +179,16 @@ class _PlayerPageState extends State initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, ); _pageController = logic.pages; - if (!widget.embedded) { - _loadingIndex = logic.session.currentIndex; + if (!widget.embedded && + !logic.isEpisodeLocked(logic.session.currentEpisode)) { _loadingVisible = true; _loadingRemoved = false; + if (kDebugMode) { + debugPrint( + '[player-entry] phase=loading-shown-once ' + 'index=${logic.session.currentIndex}', + ); + } } } @@ -352,6 +358,15 @@ class _PlayerPageState extends State notification.dragDetails != null) { _swipeStartedAt = DateTime.now(); _swipePrewarmCaptured = false; + if (!widget.embedded && !_loadingRemoved) { + setState(() { + _loadingVisible = false; + _loadingRemoved = true; + }); + if (kDebugMode) { + debugPrint('[player-entry] phase=loading-dismissed-on-swipe'); + } + } } else if (notification is ScrollUpdateNotification && !_swipePrewarmCaptured && notification.dragDetails != null) { @@ -544,14 +559,12 @@ class _PlayerPageState extends State final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; - final currentIndex = logic.session.currentIndex; - if (!widget.embedded && _loadingIndex != currentIndex) { - _loadingIndex = currentIndex; - _videoReadyIndex = null; - _loadingVisible = true; - _loadingRemoved = false; - _loadingFadeScheduled = false; - } + final activePort = logic.session.active; + final activeVideo = activePort is VideoPlayerPort ? activePort : null; + final canWaitForVideoFrame = + activeVideo != null && + activeVideo.controller.value.isInitialized && + !activeVideo.controller.value.hasError; final entryUnavailable = logic.ticketError != null || logic.blockedEpisodeId != null || @@ -560,10 +573,10 @@ class _PlayerPageState extends State if (!widget.embedded && !_loadingRemoved && !_loadingFadeScheduled && - entryUnavailable) { + entryUnavailable && + !canWaitForVideoFrame) { _scheduleLoadingFade('entry-unavailable'); } - final activePort = logic.session.active; if (!widget.embedded && !_loadingRemoved && !_loadingFadeScheduled && @@ -631,8 +644,17 @@ class _PlayerPageState extends State pageController: _pageController, slots: _renderSlots(indices), onCurrentVideoVisible: _handleCurrentVideoVisible, - showNeighbors: widget.embedded || _loadingRemoved, ), + if (!widget.embedded && !_loadingRemoved) + Center( + child: IgnorePointer( + key: const Key('player-entry-loading-overlay'), + child: PlayerLoadingOverlay( + opacity: _loadingVisible ? 1 : 0, + onFadeEnd: _handleLoadingFadeEnd, + ), + ), + ), pageLayer, ], ); @@ -675,14 +697,6 @@ class _PlayerPageState extends State ), ), ), - if (!widget.embedded && !_loadingRemoved) - Positioned.fill( - child: PlayerLoadingOverlay( - key: ValueKey('player-loading-${_loadingIndex ?? currentIndex}'), - opacity: _loadingVisible ? 1 : 0, - onFadeEnd: _handleLoadingFadeEnd, - ), - ), ], ); final player = AnnotatedRegion( @@ -717,7 +731,7 @@ class _PlayerPageState extends State } void _handleCurrentVideoVisible(int? index) { - if (!mounted) return; + if (!mounted || _loadingRemoved) return; final currentIndex = logic.session.currentIndex; if (index == null || index != currentIndex || @@ -733,6 +747,14 @@ class _PlayerPageState extends State } WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + _loadingRemoved || + index != logic.session.currentIndex) { + return; + } + if (kDebugMode) { + debugPrint('[player-entry] phase=video-frame-painted index=$index'); + } _startLoadingFade(currentIndex); }); }); @@ -938,11 +960,20 @@ class _PlayerPageState extends State if (selected && videoReady) _logSwipeReveal(); final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; + final entryLoading = !widget.embedded && !_loadingRemoved; + if (kDebugMode && entryLoading && selected && !_loggedLoadingUiVisible) { + _loggedLoadingUiVisible = true; + debugPrint( + '[player-entry] phase=loading-ui-visible ' + 'index=$index top=1 rail=1 progress=1', + ); + } return Stack( fit: StackFit.expand, children: [ if (selected && + !entryLoading && !locked && !theater && !ready && @@ -962,7 +993,7 @@ class _PlayerPageState extends State ), ), // 票据三态之一:起播前正在向后端要签名地址 - if (selected && !theater && logic.ticketLoading) + if (selected && !entryLoading && !theater && logic.ticketLoading) const Center( child: SizedBox.square( dimension: _playerLoadingSize, diff --git a/lib/pages/player/widgets/player_loading_overlay.dart b/lib/pages/player/widgets/player_loading_overlay.dart index eb890a3..a7ce449 100644 --- a/lib/pages/player/widgets/player_loading_overlay.dart +++ b/lib/pages/player/widgets/player_loading_overlay.dart @@ -17,36 +17,26 @@ class PlayerLoadingOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return IgnorePointer( - key: const Key('player-entry-loading-overlay'), - child: AnimatedOpacity( - opacity: opacity, - duration: const Duration(milliseconds: 140), - curve: Curves.easeOut, - onEnd: onFadeEnd, - child: ColoredBox( - color: AppColors.mediaBlack, - child: Center( - child: Container( - key: const Key('player-loading-surface'), - width: AppDimens.refreshIndicatorSize, - height: AppDimens.refreshIndicatorSize, - decoration: BoxDecoration( - color: AppColors.refreshIndicatorSurface, - borderRadius: BorderRadius.circular( - AppDimens.refreshIndicatorRadius, - ), - ), - alignment: Alignment.center, - child: const SizedBox.square( - dimension: AppDimens.refreshSpinnerSize, - child: CircularProgressIndicator( - key: Key('player-entry-loading'), - color: Colors.white, - strokeWidth: AppDimens.refreshSpinnerStroke, - ), - ), - ), + return AnimatedOpacity( + opacity: opacity, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + onEnd: onFadeEnd, + child: Container( + key: const Key('player-loading-surface'), + width: AppDimens.refreshIndicatorSize, + height: AppDimens.refreshIndicatorSize, + decoration: BoxDecoration( + color: AppColors.refreshIndicatorSurface, + borderRadius: BorderRadius.circular(AppDimens.refreshIndicatorRadius), + ), + alignment: Alignment.center, + child: const SizedBox.square( + dimension: AppDimens.refreshSpinnerSize, + child: CircularProgressIndicator( + key: Key('player-entry-loading'), + color: Colors.white, + strokeWidth: AppDimens.refreshSpinnerStroke, ), ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 0a89365..d5f90b8 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -304,6 +304,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, inactiveTrackColor: AppColors.inactiveTrack, + fallbackDuration: Duration(seconds: data.episode.durationSec), onScrubStart: actions.onScrubStart, onScrubEnd: actions.onScrubEnd, onSeek: actions.onSeek, diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 28489dd..4cf3c56 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -53,13 +53,11 @@ class PlayerRenderStack extends StatefulWidget { required this.pageController, required this.slots, this.onCurrentVideoVisible, - this.showNeighbors = true, }); final PageController pageController; final Map slots; final ValueChanged? onCurrentVideoVisible; - final bool showNeighbors; @override State createState() => _PlayerRenderStackState(); @@ -117,13 +115,11 @@ class _PlayerRenderStackState extends State { : MediaQuery.sizeOf(context).height; final pageOffset = _pageOffset(); final currentPage = _currentSlot.pagePosition ?? pageOffset; - final slots = widget.showNeighbors - ? [ - _slotData(PlayerRenderSlotRole.previous), - _slotData(PlayerRenderSlotRole.current), - _slotData(PlayerRenderSlotRole.next), - ] - : [_slotData(PlayerRenderSlotRole.current)]; + final slots = [ + _slotData(PlayerRenderSlotRole.previous), + _slotData(PlayerRenderSlotRole.current), + _slotData(PlayerRenderSlotRole.next), + ]; return Stack( key: const Key('player-render-stack'), fit: StackFit.expand, diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index f0bbf32..7e566f9 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -42,6 +42,7 @@ class SeekProgressBar extends StatefulWidget { this.spriteAxisService, this.episodeId, this.thumbnailsUrl, + this.fallbackDuration = Duration.zero, }); final ValueListenable? valueListenable; @@ -57,6 +58,7 @@ class SeekProgressBar extends StatefulWidget { final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; final String? thumbnailsUrl; + final Duration fallbackDuration; @override State createState() => _SeekProgressBarState(); @@ -220,7 +222,10 @@ class _SeekProgressBarState extends State { } Widget _buildTrack(BuildContext context, VideoPlayerValue video) { - final total = video.duration.inMilliseconds; + final duration = video.duration > Duration.zero + ? video.duration + : widget.fallbackDuration; + final total = duration.inMilliseconds; final scrubbing = _dragging; final loading = !widget.locked && !scrubbing && _buffering; final enabled = @@ -234,7 +239,7 @@ class _SeekProgressBarState extends State { final fraction = _previewFraction.value ?? played; final previewPosition = _previewFraction.value == null ? video.position - : seekPositionForFraction(fraction, video.duration); + : seekPositionForFraction(fraction, duration); final showPreview = scrubbing && widget.showTimes && @@ -248,11 +253,26 @@ class _SeekProgressBarState extends State { final track = Column( mainAxisSize: MainAxisSize.min, children: [ + if (widget.showTimes && !video.isInitialized) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '00:00 / ${_format(duration)}', + key: const Key('player-progress-uninitialized-time'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ), if (widget.showTimes && scrubbing && !showPreview) Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( - '${_format(previewPosition)} / ${_format(video.duration)}', + '${_format(previewPosition)} / ${_format(duration)}', textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onPrimary, @@ -350,7 +370,7 @@ class _SeekProgressBarState extends State { thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, position: previewPosition, - duration: video.duration, + duration: duration, ), ), ], diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index dd45c1d..915cadd 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -139,6 +139,15 @@ void main() { expect(feedPlayer.disposed, isFalse); expect(feedPlayer.playing, isFalse); + final fullCurrentSurface = find.descendant( + of: fullPlayer, + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + final fullCurrentSurfaceState = tester.state(fullCurrentSurface); + final loadingOverlay = find.byKey( + const Key('player-entry-loading-overlay'), + ); + expect(loadingOverlay, findsNothing); final fullPlayerPage = find .descendant( of: _fullPlayerFinder(), @@ -149,15 +158,14 @@ void main() { ) .first; for (var index = 0; index < 4; index++) { - await tester.drag( - fullPlayerPage, - const Offset(0, -560), - ); + await tester.drag(fullPlayerPage, const Offset(0, -560)); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); + expect(loadingOverlay, findsNothing); } expect(fullLogic.session.currentEpisode.number, 5); + expect(tester.state(fullCurrentSurface), same(fullCurrentSurfaceState)); expect( fullLogic.queue.entryAt(fullLogic.session.currentIndex).drama.id, sourceDramaId, @@ -260,7 +268,7 @@ void main() { await _pumpApp( tester, playerFactory: (episode) => episode.id == 'the-signal-demo-2' - ? _GatedPlayer(initialization.future) + ? _GatedVideoPlayer(episode, initialization.future) : routeFactory.create(episode), ); await tester.tap(find.byKey(const Key('tab-recommend'))); @@ -270,13 +278,18 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-episode-2'))); await tester.pump(); - await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 300)); final loadingOverlay = find.byKey( const Key('player-entry-loading-overlay'), ); final fullPlayer = find.byType(PlayerPage).last; final fullLogic = _pageLogic(tester, fullPlayer); + final fullCurrentSurface = find.descendant( + of: fullPlayer, + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + final fullCurrentSurfaceState = tester.state(fullCurrentSurface); final fullPlayerPage = find .descendant( of: fullPlayer, @@ -288,6 +301,7 @@ void main() { .first; expect(loadingOverlay, findsOneWidget); expect(tester.widget(loadingOverlay).ignoring, isTrue); + expect(tester.getSize(loadingOverlay), const Size(40, 40)); expect( tester .widget( @@ -315,39 +329,51 @@ void main() { .color, Colors.white, ); - expect( - tester - .widget( - find.descendant( - of: loadingOverlay, - matching: find.byType(ColoredBox), - ), - ) - .color, - AppColors.mediaBlack, - ); final loadingSurface = tester.widget( find.byKey(const Key('player-loading-surface')), ); final decoration = loadingSurface.decoration! as BoxDecoration; expect(decoration.color, AppColors.refreshIndicatorSurface); expect(decoration.borderRadius, BorderRadius.circular(20)); + expect( + find.descendant(of: loadingOverlay, matching: find.byType(ColoredBox)), + findsNothing, + ); - // IgnorePointer 让加载中的播放暂停与上下滑仍能透传。 + expect(find.byKey(const Key('player-back')).hitTestable(), findsOneWidget); + expect( + find.byKey(const Key('player-episode-heading')).hitTestable(), + findsOneWidget, + ); + expect( + find.byKey(const Key('player-drama-title')).hitTestable(), + findsOneWidget, + ); + expect( + find + .descendant( + of: find.byKey(const Key('player-rail-like')), + matching: find.byType(IconButton), + ) + .hitTestable(), + findsOneWidget, + ); + expect(find.byKey(const Key('player-progress')), findsOneWidget); + expect(find.text('00:00 / 01:00'), findsOneWidget); + + // IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。 expect(fullLogic.session.userWantsPlay, isTrue); await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable()); await tester.pump(); expect(fullLogic.session.userWantsPlay, isFalse); final indexBeforeSwipe = fullLogic.session.currentIndex; - await tester.drag( - fullPlayerPage, - const Offset(0, -560), - ); + await tester.drag(fullPlayerPage, const Offset(0, -560)); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(fullLogic.session.currentIndex, isNot(indexBeforeSwipe)); + expect(tester.state(fullCurrentSurface), same(fullCurrentSurfaceState)); - // IgnorePointer 让加载中的返回按钮仍可点击。 + // 切集完成后返回按钮仍可正常点击。 await tester.pumpAndSettle(); expect(loadingOverlay, findsNothing); await tester.tap(find.byKey(const Key('player-back')).hitTestable()); @@ -1900,6 +1926,15 @@ class _GatedPlayer implements PlayerPort { Future dispose() async {} } +class _GatedVideoPlayer extends VideoPlayerPort { + _GatedVideoPlayer(super.episode, this.initialization); + + final Future initialization; + + @override + Future initialize() => initialization; +} + const _lockedDrama = Drama( id: 'locked-demo', title: 'Locked Demo', diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index ed400a2..19a5681 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -113,6 +113,7 @@ Drama _drama( number: index + 1, title: 'Episode ${index + 1}', isFree: true, + durationSec: 60, videoUrl: 'https://example.test/$format-$index.mp4', likeCount: likeCount, commentCount: commentCount,