fix(player): 持久化竖滑播放画面层

This commit is contained in:
Zhou Weidong committed 2026-10-26 15:34:24 +08:00
1 parent 5dafc68775
commit 2a32dec183
5 files changed
+593 -101

No files matched your search

+178 -92
View File
@@ -40,6 +40,7 @@ import 'widgets/player_center_toggle.dart';
import 'widgets/player_more_sheet.dart';
import 'widgets/player_overlay.dart';
import 'widgets/player_overlay_models.dart';
import 'widgets/player_render_stack.dart';
import 'widgets/player_speed_sheet.dart';
import 'widgets/player_unlock_sheet.dart';
import 'widgets/player_opening_frame.dart';
@@ -528,6 +529,14 @@ class _PlayerPageState extends State<PlayerPage>
itemCount: indices.length,
onPageChanged: (position) {
final index = indices[position];
if (kDebugMode) {
debugPrint(
'[player-page] '
'ts=${DateTime.now().millisecondsSinceEpoch} '
'phase=session-index '
'previous=${logic.session.currentIndex} next=$index',
);
}
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(_selectPage(index));
if (position >= indices.length - 3) {
@@ -535,16 +544,37 @@ class _PlayerPageState extends State<PlayerPage>
if (loadMore != null) unawaited(loadMore());
}
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
itemBuilder: (context, position) {
final index = indices[position];
if (kDebugMode) {
debugPrint(
'[player-page] '
'ts=${DateTime.now().millisecondsSinceEpoch} '
'phase=item-build page=$position index=$index',
);
}
return _buildEpisode(context, index, interactions);
},
),
);
if (widget.onRefreshFeed == null) return pages;
return FeedPullRefresh(
pageController: logic.pages,
topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
onRefresh: _refreshHomeFeed,
child: pages,
final pageLayer = widget.onRefreshFeed == null
? pages
: FeedPullRefresh(
pageController: logic.pages,
topInset:
MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
onRefresh: _refreshHomeFeed,
child: pages,
);
return Stack(
fit: StackFit.expand,
children: [
PlayerRenderStack(
pageController: logic.pages,
slots: _renderSlots(indices),
),
pageLayer,
],
);
},
);
@@ -555,7 +585,6 @@ class _PlayerPageState extends State<PlayerPage>
if (widget.homeMode &&
widget.showHomeFeedHeader &&
!_scrubbing &&
_chromeVisible &&
!_boosting &&
!logic.pip &&
!logic.pipPreparing)
@@ -616,6 +645,145 @@ class _PlayerPageState extends State<PlayerPage>
_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<PlayerRenderSlotRole, PlayerRenderSlotData> _renderSlots(
List<int> 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<int> indices, {
required int currentIndex,
}) {
if (index == null || index < 0 || index >= logic.sessionEpisodes.length) {
return PlayerRenderSlotData.empty(role);
}
final episode = _resolvedEpisode(index);
final selected = index == currentIndex;
final locked =
logic.isEpisodeLocked(episode) ||
(selected && logic.session.isLocked) ||
logic.blockedEpisodeId == episode.id;
final port = locked
? null
: (selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final handoff = selected && !locked ? logic.session.handoffAt(index) : null;
final handoffPort = handoff is VideoPlayerPort ? handoff : null;
final videoReady = videoPort?.controller.value.isInitialized ?? false;
final theater = logic.pip || logic.pipPreparing;
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
final preparedFrameAvailable =
!locked &&
videoReady &&
(selected
? logic.session.reusedWarmAt(index)
: logic.session.preparedPlayerAt(index) != null);
final thumbnailsUrl = episode.thumbnailsUrl;
final posterUrl = episode.posterUrl;
final posterReady = posterUrl?.trim().isNotEmpty ?? false;
final thumbnailReady =
thumbnailsUrl != null &&
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
if (!locked && !posterReady && thumbnailsUrl != null) {
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
}
if (selected &&
!locked &&
videoPort != null &&
videoPort.controller.value.isInitialized) {
unawaited(
_spriteAxisService.prepareAt(
thumbnailsUrl,
videoPort.controller.value.position,
),
);
}
final pagePosition = indices.indexOf(index);
return PlayerRenderSlotData(
role: role,
index: index,
pagePosition: pagePosition >= 0 ? pagePosition.toDouble() : null,
controller: locked ? null : videoPort?.controller,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
warmFrameAvailable: preparedFrameAvailable,
thumbnailReady: thumbnailReady,
posterReady: posterReady,
fallback: handoffPort != null
? PlayerOpeningFallback.handoff
: playerOpeningFallback(
posterUrl: posterUrl,
thumbnailsUrl: thumbnailsUrl,
),
warmSlotReused: logic.session.reusedWarmAt(index),
waitForPlaybackFrame: logic.session.userWantsPlay,
placeholder: locked
? null
: handoffPort != null
? ControllerFramePlaceholder(
controller: handoffPort.controller,
theater: theater,
)
: playerOpeningFrame(
thumbnailsUrl,
posterUrl: posterUrl,
spriteSource: _spriteAxisService,
fit: videoFit,
),
);
}
Widget _buildEpisode(
BuildContext context,
int index,
@@ -624,25 +792,7 @@ class _PlayerPageState extends State<PlayerPage>
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
? logic.sessionEpisodes[index]
: entry.episode;
final displayed = widget.homeMode
? logic.displayEpisode(drama, sessionEpisode)
: sessionEpisode;
final needsSessionThumbnails =
(displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) &&
sessionEpisode.thumbnailsUrl != null;
final needsSessionPoster =
(displayed.posterUrl == null || displayed.posterUrl!.isEmpty) &&
sessionEpisode.posterUrl != null;
final episode = needsSessionThumbnails || needsSessionPoster
? displayed.withVideoUrl(
displayed.videoUrl,
thumbnailsUrl: sessionEpisode.thumbnailsUrl,
posterUrl: sessionEpisode.posterUrl,
)
: displayed;
final episode = _resolvedEpisode(index);
final selected = index == logic.session.currentIndex;
final locked =
logic.isEpisodeLocked(episode) ||
@@ -655,79 +805,15 @@ class _PlayerPageState extends State<PlayerPage>
: (selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final handoff = selected && !locked ? logic.session.handoffAt(index) : null;
final handoffPort = handoff is VideoPlayerPort ? handoff : null;
// 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。
final videoReady = videoPort?.controller.value.isInitialized ?? false;
if (selected && videoReady) _logSwipeReveal();
final ready = !locked && (videoReady || port != null);
final theater = logic.pip || logic.pipPreparing;
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
final preparedFrameAvailable =
!locked &&
videoReady &&
(selected
? logic.session.reusedWarmAt(index)
: logic.session.preparedPlayerAt(index) != null);
final warmSlotReused = logic.session.reusedWarmAt(index);
final thumbnailsUrl = episode.thumbnailsUrl;
final posterUrl = episode.posterUrl;
final posterReady = posterUrl?.trim().isNotEmpty ?? false;
final openingFrameReady =
thumbnailsUrl != null &&
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
if (!posterReady && thumbnailsUrl != null) {
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
}
if (selected &&
!locked &&
videoPort != null &&
videoPort.controller.value.isInitialized) {
unawaited(
_spriteAxisService.prepareAt(
episode.thumbnailsUrl,
videoPort.controller.value.position,
),
);
}
return Stack(
fit: StackFit.expand,
children: [
if (widget.homeMode)
const ColoredBox(
key: Key('home-feed-black-background'),
color: AppColors.mediaBlack,
),
PlayerVideoSurface(
controller: locked ? null : videoPort?.controller,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
warmFrameAvailable: preparedFrameAvailable,
warmSlotReused: warmSlotReused,
thumbnailReady: openingFrameReady,
posterReady: posterReady,
fallback: handoffPort != null
? PlayerOpeningFallback.handoff
: playerOpeningFallback(
posterUrl: posterUrl,
thumbnailsUrl: thumbnailsUrl,
),
waitForPlaybackFrame: logic.session.userWantsPlay,
placeholder: locked
? null
: handoffPort != null
? ControllerFramePlaceholder(
controller: handoffPort.controller,
theater: theater,
)
: playerOpeningFrame(
thumbnailsUrl,
posterUrl: posterUrl,
spriteSource: _spriteAxisService,
fit: videoFit,
),
),
if (selected &&
!locked &&
!theater &&
@@ -0,0 +1,203 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
import 'player_opening_frame.dart';
import 'player_video_surface.dart';
enum PlayerRenderSlotRole { previous, current, next }
class PlayerRenderSlotData {
const PlayerRenderSlotData({
required this.role,
this.index,
this.pagePosition,
this.controller,
this.videoUrl = '',
this.theater = false,
this.placeholder,
this.warmFrameAvailable = false,
this.thumbnailReady = false,
this.posterReady = false,
this.fallback = PlayerOpeningFallback.black,
this.warmSlotReused = false,
this.waitForPlaybackFrame = false,
});
final PlayerRenderSlotRole role;
final int? index;
final double? pagePosition;
final VideoPlayerController? controller;
final String videoUrl;
final bool theater;
final Widget? placeholder;
final bool warmFrameAvailable;
final bool thumbnailReady;
final bool posterReady;
final PlayerOpeningFallback fallback;
final bool warmSlotReused;
final bool waitForPlaybackFrame;
factory PlayerRenderSlotData.empty(PlayerRenderSlotRole role) {
return PlayerRenderSlotData(role: role);
}
}
/// 播放画面不随 PageView 生命周期销毁,三个固定槽位只更新控制器和位置。
class PlayerRenderStack extends StatefulWidget {
const PlayerRenderStack({
super.key,
required this.pageController,
required this.slots,
});
final PageController pageController;
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
@override
State<PlayerRenderStack> createState() => _PlayerRenderStackState();
}
class _PlayerRenderStackState extends State<PlayerRenderStack> {
int? _lastCurrentIndex;
@override
void initState() {
super.initState();
_lastCurrentIndex = _currentSlot.index;
_log('init current=${_lastCurrentIndex ?? '-'}');
}
@override
void didUpdateWidget(covariant PlayerRenderStack oldWidget) {
super.didUpdateWidget(oldWidget);
final currentIndex = _currentSlot.index;
if (_lastCurrentIndex != currentIndex) {
_log(
'session-index previous=${_lastCurrentIndex ?? '-'} '
'current=${currentIndex ?? '-'}',
);
_lastCurrentIndex = currentIndex;
}
}
@override
void dispose() {
_log('dispose current=${_lastCurrentIndex ?? '-'}');
super.dispose();
}
PlayerRenderSlotData get _currentSlot =>
widget.slots[PlayerRenderSlotRole.current] ??
PlayerRenderSlotData.empty(PlayerRenderSlotRole.current);
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: AnimatedBuilder(
animation: widget.pageController,
builder: (context, _) {
return LayoutBuilder(
builder: (context, constraints) {
final viewportHeight = constraints.maxHeight.isFinite
? constraints.maxHeight
: MediaQuery.sizeOf(context).height;
final pageOffset = _pageOffset();
final currentPage = _currentSlot.pagePosition ?? pageOffset;
final slots = [
_slotData(PlayerRenderSlotRole.previous),
_slotData(PlayerRenderSlotRole.current),
_slotData(PlayerRenderSlotRole.next),
];
return Stack(
key: const Key('player-render-stack'),
fit: StackFit.expand,
clipBehavior: Clip.hardEdge,
children: [
const ColoredBox(
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
for (final slot in slots)
_positionedSurface(
slot,
pageOffset: pageOffset,
viewportHeight: viewportHeight,
fallbackPage: _fallbackPage(slot.role, currentPage),
),
],
);
},
);
},
),
);
}
PlayerRenderSlotData _slotData(PlayerRenderSlotRole role) {
return widget.slots[role] ?? PlayerRenderSlotData.empty(role);
}
double _pageOffset() {
if (!widget.pageController.hasClients) {
return widget.pageController.initialPage.toDouble();
}
final position = widget.pageController.position;
if (!position.hasPixels ||
!position.hasViewportDimension ||
position.viewportDimension <= 0) {
return widget.pageController.initialPage.toDouble();
}
return position.pixels / position.viewportDimension;
}
double _fallbackPage(PlayerRenderSlotRole role, double currentPage) {
return switch (role) {
PlayerRenderSlotRole.previous => currentPage - 1,
PlayerRenderSlotRole.current => currentPage,
PlayerRenderSlotRole.next => currentPage + 1,
};
}
Widget _positionedSurface(
PlayerRenderSlotData slot, {
required double pageOffset,
required double viewportHeight,
required double fallbackPage,
}) {
final slotPage = slot.pagePosition ?? fallbackPage;
final offset = (slotPage - pageOffset) * viewportHeight;
return Positioned.fill(
child: Transform.translate(
key: Key('player-render-transform-${slot.role.name}'),
offset: Offset(0, offset),
child: PlayerVideoSurface(
key: ValueKey('player-render-slot-${slot.role.name}'),
debugSlot: slot.role.name,
debugIndex: slot.index,
showFallbackBackground: false,
controller: slot.controller,
videoUrl: slot.videoUrl,
theater: slot.theater,
placeholder: slot.placeholder,
warmFrameAvailable: slot.warmFrameAvailable,
thumbnailReady: slot.thumbnailReady,
posterReady: slot.posterReady,
fallback: slot.fallback,
warmSlotReused: slot.warmSlotReused,
waitForPlaybackFrame: slot.waitForPlaybackFrame,
),
),
);
}
void _log(String message) {
if (!kDebugMode) return;
debugPrint(
'[player-render] ts=${DateTime.now().millisecondsSinceEpoch} $message',
);
}
}
@@ -25,6 +25,9 @@ class PlayerVideoSurface extends StatefulWidget {
this.fallback = PlayerOpeningFallback.black,
this.warmSlotReused = false,
this.waitForPlaybackFrame = false,
this.debugSlot = 'default',
this.debugIndex,
this.showFallbackBackground = true,
});
final VideoPlayerController? controller;
@@ -49,6 +52,9 @@ class PlayerVideoSurface extends StatefulWidget {
/// 自动播放中的新集等待 position 首次前进后再移交画面。
final bool waitForPlaybackFrame;
final String debugSlot;
final int? debugIndex;
final bool showFallbackBackground;
final String videoUrl;
final bool theater;
@@ -79,8 +85,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
final value = widget.controller?.value;
_updatePlaybackFlags(value);
_videoSize = value != null && value.isInitialized ? value.size : null;
_logSlot('init', value);
if (_firstFrameConfirmed(value)) {
_confirmFirstFrame(initial: true);
} else {
_logLayer('placeholder', value);
}
}
@@ -88,13 +97,19 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
_logSlot('controller-change-before', oldWidget.controller?.value);
_detachController(oldWidget.controller);
_resetForControllerChange();
_attachController(widget.controller);
final value = widget.controller?.value;
_updatePlaybackFlags(value);
_videoSize = value != null && value.isInitialized ? value.size : null;
if (_firstFrameConfirmed(value)) _confirmFirstFrame(initial: true);
_logSlot('controller-change-after', value);
if (_firstFrameConfirmed(value)) {
_confirmFirstFrame(initial: true);
} else {
_logLayer('placeholder', value);
}
return;
}
if (!_transitioning &&
@@ -119,6 +134,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
@override
void dispose() {
_logSlot('dispose', widget.controller?.value);
_detachController(widget.controller);
_crossFade
..removeStatusListener(_handleFadeStatus)
@@ -143,10 +159,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
if (widget.showFallbackBackground)
const ColoredBox(
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
if (showPlaceholder)
KeyedSubtree(
key: const Key('player-single-placeholder'),
@@ -210,14 +227,16 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
_confirmationScheduled = true;
if (kDebugMode) {
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} '
'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} '
'phase=first-frame-scheduled',
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (kDebugMode) {
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} '
'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} '
'phase=first-frame-callback mounted=$mounted',
);
}
@@ -246,6 +265,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
void _confirmFirstFrame({bool initial = false}) {
if (_frameConfirmed || _videoSize == null) return;
_frameConfirmed = true;
_logSlot('firstFrameRendered', widget.controller?.value);
final canSkipPlaceholder =
widget.warmFrameAvailable || _placeholderSnapshot == null;
if (canSkipPlaceholder) {
@@ -303,6 +323,8 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
? 'video'
: _fallbackLabel(widget.fallback);
final signature =
'slot=${widget.debugSlot} '
'index=${widget.debugIndex ?? '-'} '
'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} '
'reusedWarm=${widget.warmSlotReused ? 1 : 0} '
'frame=${_frameConfirmed ? 1 : 0} '
@@ -312,14 +334,34 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
'init=${value?.isInitialized == true ? 1 : 0} '
'position=${value?.position.inMilliseconds ?? 0} '
'playing=${value?.isPlaying == true ? 1 : 0} '
'buffering=${value?.isBuffering == true ? 1 : 0}';
'buffering=${value?.isBuffering == true ? 1 : 0} '
'placeholder=${_placeholderLabel()}';
if (signature == _lastLayerState) return;
_lastLayerState = signature;
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature',
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} $signature',
);
}
void _logSlot(String phase, VideoPlayerValue? value) {
if (!kDebugMode) return;
debugPrint(
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} '
'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} '
'phase=$phase init=${value?.isInitialized == true ? 1 : 0}',
);
}
String _placeholderLabel() {
if (_placeholderSnapshot == null) return 'none';
return switch (widget.fallback) {
PlayerOpeningFallback.handoff => 'handoff',
PlayerOpeningFallback.poster => 'poster',
PlayerOpeningFallback.thumbnails => 'thumbnails',
PlayerOpeningFallback.black => 'none',
};
}
String _fallbackLabel(PlayerOpeningFallback fallback) {
if (_placeholderSnapshot == null) return 'black';
return switch (fallback) {
+13
View File
@@ -55,6 +55,19 @@ void main() {
pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical),
hasLength(1),
);
expect(find.byKey(const Key('player-render-stack')), findsOneWidget);
final renderStackPageAncestors = tester.widgetList<PageView>(
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);
});
+148
View File
@@ -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<PlayerVideoSurface>(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<PlayerVideoSurface>(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<PlayerRenderSlotRole, PlayerRenderSlotData> _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<Transform>(
find.byKey(Key('player-render-transform-$role')),
);
return transform.transform.getTranslation().y;
}