fix(player): 收敛播放页入口加载态

This commit is contained in:
Zhou Weidong committed 2026-10-26 17:56:40 +08:00
1 parent ee4e573f4f
commit b291044acc
7 files changed
+164 -90

No files matched your search

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