fix(player): 分离首页与播放页播放上下文

This commit is contained in:
Zhou Weidong committed 2026-10-26 16:37:37 +08:00
1 parent 440188f261
commit 6667330ce0
8 files changed
+312 -362

No files matched your search

@@ -69,6 +69,7 @@ Future<void> _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async {
/// 当前集已经出画后,按手势方向静默准备上一集和下一集。
void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) {
if (!session.warmNeighborsEnabled) return;
final request = session._generation;
final nextTask = _warmOne(session, request, session._nextIndex, next: true);
final previousTask = _warmOne(
@@ -4,9 +4,6 @@ import '../models/drama.dart';
/// A player session has one navigation meaning: browse dramas or browse episodes.
class PlaybackQueue {
PlaybackQueue.fromEntries(List<PlaybackEntry> entries)
: entries = List.unmodifiable(entries);
PlaybackQueue.home(List<Drama> dramas)
: entries = List.unmodifiable(
dramas
+10 -44
View File
@@ -27,6 +27,7 @@ class PlaybackSession extends ChangeNotifier {
this.activate,
PlaybackCoordinator? coordinator,
this.waitForFrame,
this.warmNeighborsEnabled = true,
}) : _coordinator = coordinator ?? PlaybackCoordinator();
final List<Episode> episodes;
@@ -39,6 +40,7 @@ class PlaybackSession extends ChangeNotifier {
/// Runs only when a slot becomes current (e.g. progress listeners).
final PlayerPreparation? activate;
final Future<void> Function()? waitForFrame;
final bool warmNeighborsEnabled;
final PlaybackCoordinator _coordinator;
final Set<_PlayerSlot> _live = {};
_PlayerSlot? _active;
@@ -80,6 +82,8 @@ class PlaybackSession extends ChangeNotifier {
String? get error => _error;
// Includes initialization and asynchronous disposal, until native release ends.
int get livePlayerCount => _live.length;
bool get activeSlotActivated => _active?.activated ?? false;
bool get activeSlotPlaying => _active?.playing ?? false;
int? get preloadedIndex => _next?.prepared == true ? _next?.index : null;
int? get previousPreloadedIndex =>
_previous?.prepared == true ? _previous?.index : null;
@@ -137,50 +141,9 @@ class PlaybackSession extends ChangeNotifier {
if (_visible && !_overlayOpen) _warmNeighbors(this);
}
/// 在剧维度和集维度之间切换队列,但保留当前 active controller 与播放状态。
void remapQueuePreservingActive(
List<Episode> nextEpisodes, {
required int currentIndex,
int? preloadIndex,
int? previousIndex,
bool notify = true,
}) {
if (_closed ||
currentIndex < 0 ||
currentIndex >= nextEpisodes.length ||
_active == null) {
return;
}
_generation++;
episodes
..clear()
..addAll(nextEpisodes);
_active!.index = currentIndex;
_currentIndex = currentIndex;
_selected = true;
_loading = false;
_error = null;
_rememberNeighbors(preloadIndex, previousIndex);
final staleNext = _next;
final stalePrevious = _previous;
final staleHandoff = _handoff;
_next = null;
_previous = null;
_handoff = null;
if (notify) _notify();
unawaited(
_enqueue(() async {
await _retire(staleNext);
await _retire(stalePrevious);
await _retire(staleHandoff);
}),
);
if (_visible && !_overlayOpen) _warmNeighbors(this);
}
void _rememberNeighbors(int? preloadIndex, int? previousIndex) {
_nextIndex = preloadIndex;
_previousIndex = previousIndex;
_nextIndex = warmNeighborsEnabled ? preloadIndex : null;
_previousIndex = warmNeighborsEnabled ? previousIndex : null;
}
Future<void> _enqueue(Future<void> Function() operation) {
@@ -247,10 +210,13 @@ class PlaybackSession extends ChangeNotifier {
int index, {
int? preloadIndex,
int? previousIndex,
bool? userWantsPlay,
}) async {
if (_closed || index < 0 || index >= episodes.length) return;
// 切换到不同剧集时恢复播放;仍停留在同一集时保留用户暂停。
if (!_selected || index != _currentIndex) {
if (userWantsPlay != null) {
_userWantsPlay = userWantsPlay;
} else if (!_selected || index != _currentIndex) {
_userWantsPlay = true;
}
final request = ++_generation;
@@ -6,10 +6,71 @@ part of 'playback_session.dart';
extension PlaybackSessionControls on PlaybackSession {
/// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。
void prewarmNeighbors({required bool forward}) {
if (!_visible || _overlayOpen || _active?.prepared != true) return;
if (!warmNeighborsEnabled ||
!_visible ||
_overlayOpen ||
_active?.prepared != true) {
return;
}
_warmNeighbors(this, preferredNext: forward);
}
/// 路由交接时同步暂停当前原生播放器,controller 仍由 session 持有。
Future<void> pauseForRouteHandoff() {
if (_closed) return Future<void>.value();
_routeVisible = false;
return _enqueue(() async {
final slot = _active;
if (slot == null) return;
await slot.player.pause();
slot.playing = false;
});
}
/// 路由恢复可见后立即同步当前 controller,并等待原生播放状态落地。
Future<void> resumeForRouteHandoff() {
if (_closed) return Future<void>.value();
_routeVisible = true;
return _enqueue(() async {
final slot = _active;
if (kDebugMode) {
debugPrint(
'[feed-handoff] phase=resume slot=${slot?.index} '
'activated=${slot?.activated} playing=${slot?.playing} '
'retiring=${slot?.retiring} current=$_currentIndex '
'visible=$_visible user=$_userWantsPlay loading=$_loading',
);
}
if (slot == null || slot.retiring) return;
if (!slot.activated) {
try {
await activate?.call(episodes[slot.index], slot.player);
} catch (error) {
if (kDebugMode) {
debugPrint('[feed-handoff] phase=activation-failed error=$error');
}
}
if (_closed || _active != slot || slot.retiring) {
if (kDebugMode) {
debugPrint(
'[feed-handoff] phase=activation-aborted closed=$_closed '
'activeSame=${identical(_active, slot)} retiring=${slot.retiring}',
);
}
return;
}
slot.activated = true;
}
await _applyPlayback(slot);
if (kDebugMode) {
debugPrint(
'[feed-handoff] phase=resumed activated=${slot.activated} '
'playing=${slot.playing}',
);
}
});
}
/// 选中锁定集时立即停播并释放所有原生播放器,不创建新片源。
Future<void> selectLocked(
int index, {
+47 -67
View File
@@ -192,6 +192,7 @@ class PlayerLogic extends ChangeNotifier {
void start({
required PlayerFactory factory,
required PlaybackCoordinator coordinator,
bool initialUserWantsPlay = true,
}) {
_started = true;
_sessionFactory = factory;
@@ -209,69 +210,41 @@ class PlayerLogic extends ChangeNotifier {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 独立播放页把 feed 的“剧索引”切换成 series 的“集索引”,保留 active player。
void adoptSeriesHandoff({
required Drama drama,
required List<Episode> seriesEpisodes,
required int episodeIndex,
}) {
if (episodeIndex < 0 || episodeIndex >= seriesEpisodes.length) return;
homeMode = false;
embedded = false;
feedDramas = null;
pageDrama = drama;
initialIndex = episodeIndex;
queue = PlaybackQueue.series(drama);
visibleIndices = List<int>.generate(queue.length, (i) => i);
sessionEpisodes
..clear()
..addAll(seriesEpisodes);
session.remapQueuePreservingActive(
List<Episode>.of(seriesEpisodes),
currentIndex: episodeIndex,
notify: false,
preloadIndex: episodeIndex + 1 < seriesEpisodes.length
? episodeIndex + 1
: null,
previousIndex: episodeIndex > 0 ? episodeIndex - 1 : null,
unawaited(
selectEpisode(
initialIndex,
userWantsPlay: initialUserWantsPlay ? null : false,
),
);
}
/// 返回推荐页时恢复剧维度 queue,并把当前集写回该剧的 feed 条目。
void restoreFeedHandoff({
required PlayerPage page,
required PlaybackQueue sourceQueue,
required List<Episode> sourceEpisodes,
required int sourceIndex,
}) {
if (sourceIndex < 0 || sourceIndex >= sourceQueue.length) return;
final currentEpisode = session.currentEpisode;
final feedEpisodes = List<Episode>.of(sourceEpisodes);
feedEpisodes[sourceIndex] = currentEpisode;
final entries = List<PlaybackEntry>.of(sourceQueue.entries);
entries[sourceIndex] = PlaybackEntry(
entries[sourceIndex].drama,
currentEpisode,
);
feedDramas = page.feedDramas;
pageDrama = page.drama;
homeMode = page.homeMode;
embedded = page.embedded;
initialIndex = sourceIndex;
queue = PlaybackQueue.fromEntries(entries);
visibleIndices = List<int>.generate(queue.length, (i) => i);
sessionEpisodes
..clear()
..addAll(feedEpisodes);
session.remapQueuePreservingActive(
List<Episode>.of(feedEpisodes),
currentIndex: sourceIndex,
preloadIndex: nextVisibleIndex(sourceIndex),
previousIndex: previousVisibleIndex(sourceIndex),
);
/// 独立播放页覆盖首页时只暂停并保存进度,首页 session 与 controller 保持存活。
Future<void> pauseForFullPlayer() async {
if (!_started) return;
cancelScrub();
await session.pauseForRouteHandoff();
await saveCurrentProgress();
}
/// 返回首页前把仍存活的 feed controller 定位到第 1 集起点。
Future<void> prepareFeedResume() async {
if (!_started) return;
await session.playbackSettled;
final player = session.active;
if (player is VideoPlayerPort && player.controller.value.isInitialized) {
await player.seekTo(Duration.zero);
}
}
/// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。
Future<void> resumeAfterFullPlayer() async {
if (!_started) return;
if (visibleIndices.isNotEmpty) {
await session.resumeForRouteHandoff();
} else {
session.setRouteVisible(false);
await session.playbackSettled;
}
}
/// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。
@@ -310,6 +283,7 @@ class PlayerLogic extends ChangeNotifier {
activate: activatePlayer,
coordinator: coordinator,
waitForFrame: _waitForFrame,
warmNeighborsEnabled: !homeMode,
);
}
@@ -1053,7 +1027,11 @@ class PlayerLogic extends ChangeNotifier {
}
}
Future<void> selectEpisode(int index, {int? revision}) async {
Future<void> selectEpisode(
int index, {
int? revision,
bool? userWantsPlay,
}) async {
final feedRevisionAtStart = revision ?? feedRevision;
final request = ++pageRequest;
cancelScrub();
@@ -1088,7 +1066,7 @@ class PlayerLogic extends ChangeNotifier {
return;
}
if (!ready) return;
await _selectVisible(index);
await _selectVisible(index, userWantsPlay: userWantsPlay);
}
/// 收费集只有在本会话确认可播或解锁成功后才解除门禁。
@@ -1098,11 +1076,12 @@ class PlayerLogic extends ChangeNotifier {
}
/// 按可见队列选择一集,并预热上一集与下一集。
Future<void> _selectVisible(int index) {
Future<void> _selectVisible(int index, {bool? userWantsPlay}) {
return session.select(
index,
preloadIndex: nextVisibleIndex(index),
previousIndex: previousVisibleIndex(index),
userWantsPlay: userWantsPlay,
);
}
@@ -1237,9 +1216,7 @@ class PlayerLogic extends ChangeNotifier {
Drama drama,
int index,
) async {
session.setRouteVisible(false);
await session.playbackSettled;
await saveCurrentProgress();
await pauseForFullPlayer();
if (!context.mounted) return;
await Navigator.of(context).push(
FadePageRoute<void>(
@@ -1248,10 +1225,13 @@ class PlayerLogic extends ChangeNotifier {
initialIndex: index,
wallet: wallet,
downloads: downloads,
isolatePlaybackSession: true,
),
),
);
if (isMounted()) session.setRouteVisible(active);
if (!isMounted()) return;
await prepareFeedResume();
await resumeAfterFullPlayer();
}
Future<void> showEpisodes(BuildContext context) async {
+34 -88
View File
@@ -15,7 +15,6 @@ import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/sprite_thumbnail_axis.dart';
import '../../core/data/services/playback_thumbnail_service.dart';
@@ -59,26 +58,12 @@ const double _feedRefreshTopGap = 56;
class PlayerPageHandoff {
PlayerPageHandoff({
required this.logic,
required this.sourceQueue,
required this.sourceEpisodes,
required this.sourceIndex,
required this.seriesDrama,
required this.seriesEpisodes,
required this.episodeIndex,
required this.prepareSource,
required this.sourceUserWantsPlay,
});
final PlayerLogic logic;
final PlaybackQueue sourceQueue;
final List<Episode> sourceEpisodes;
final int sourceIndex;
final Drama seriesDrama;
final List<Episode> seriesEpisodes;
final int episodeIndex;
final Future<void> Function() prepareSource;
PageController? destinationController;
VoidCallback? activateDestination;
final bool sourceUserWantsPlay;
}
class PlayerPage extends StatefulWidget {
@@ -109,6 +94,7 @@ class PlayerPage extends StatefulWidget {
this.wallet,
this.downloads,
this.handoff,
this.isolatePlaybackSession = false,
});
final Drama drama;
@@ -136,6 +122,7 @@ class PlayerPage extends StatefulWidget {
final WalletController? wallet;
final DownloadController? downloads;
final PlayerPageHandoff? handoff;
final bool isolatePlaybackSession;
@override
State<PlayerPage> createState() => _PlayerPageState();
@@ -144,7 +131,6 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends State<PlayerPage>
with WidgetsBindingObserver, RouteAware {
late final PlayerLogic logic;
late final bool _ownsLogic;
late PageController _pageController;
late final PlaybackThumbnailService _thumbnailService;
late final SpriteThumbnailAxisService _spriteAxisService;
@@ -163,8 +149,6 @@ class _PlayerPageState extends State<PlayerPage>
bool _presentationReturning = false;
bool _leavingHandoff = false;
bool _preparingFullPlayer = false;
PlayerPageHandoff? _activeHandoff;
PageController? _returnPageController;
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
@@ -175,16 +159,13 @@ class _PlayerPageState extends State<PlayerPage>
void initState() {
super.initState();
final handoff = widget.handoff;
_ownsLogic = handoff == null;
logic =
handoff?.logic ??
PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(),
wallet: widget.wallet,
downloads: widget.downloads,
);
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(),
wallet: widget.wallet,
downloads: widget.downloads,
);
logic.attach(widget, isMounted: () => mounted);
_thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource,
@@ -192,22 +173,14 @@ class _PlayerPageState extends State<PlayerPage>
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
..addListener(_handleSpriteFrameChanged);
WidgetsBinding.instance.addObserver(this);
if (handoff == null) {
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: context.read<PlaybackCoordinator>(),
);
_pageController = logic.pages;
} else {
logic.adoptSeriesHandoff(
drama: handoff.seriesDrama,
seriesEpisodes: handoff.seriesEpisodes,
episodeIndex: handoff.episodeIndex,
);
_pageController = PageController(initialPage: handoff.episodeIndex);
handoff.destinationController = _pageController;
logic.session.setRouteVisible(widget.active);
}
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: handoff == null && !widget.isolatePlaybackSession
? context.read<PlaybackCoordinator>()
: PlaybackCoordinator(),
initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true,
);
_pageController = logic.pages;
}
@override
@@ -328,11 +301,7 @@ class _PlayerPageState extends State<PlayerPage>
..removeListener(_handleSpriteFrameChanged)
..dispose();
_thumbnailService.dispose();
if (_ownsLogic) {
logic.dispose();
} else {
_pageController.dispose();
}
logic.dispose();
super.dispose();
}
@@ -769,11 +738,10 @@ class _PlayerPageState extends State<PlayerPage>
Widget _buildPresentationReturnLayer() {
final indices = logic.visibleIndices;
final controller = _returnPageController ?? _pageController;
return AnimatedBuilder(
animation: logic.session,
builder: (context, _) => PlayerRenderStack(
pageController: controller,
pageController: _pageController,
slots: _renderSlots(indices),
),
);
@@ -1188,7 +1156,6 @@ class _PlayerPageState extends State<PlayerPage>
final sourceIndex = logic.session.currentIndex;
final sourceEpisode = logic.session.currentEpisode;
final sourceQueue = logic.queue;
final sourceEpisodes = List<Episode>.of(logic.sessionEpisodes);
var seriesDrama = sourceQueue.entryAt(sourceIndex).drama;
try {
seriesDrama = await context.read<CatalogController>().requireDetail(
@@ -1217,22 +1184,17 @@ class _PlayerPageState extends State<PlayerPage>
seriesEpisodes.insert(0, sourceEpisode);
episodeIndex = 0;
}
final sourcePageController = _pageController;
final sourceUserWantsPlay = logic.session.userWantsPlay;
try {
await logic.pauseForFullPlayer();
} finally {
_preparingFullPlayer = false;
}
if (!mounted || !context.mounted) return;
final handoff = PlayerPageHandoff(
logic: logic,
sourceQueue: sourceQueue,
sourceEpisodes: sourceEpisodes,
sourceIndex: sourceIndex,
seriesDrama: seriesDrama,
seriesEpisodes: seriesEpisodes,
episodeIndex: episodeIndex,
prepareSource: _prepareSourceAfterFullPlayer,
sourceUserWantsPlay: sourceUserWantsPlay,
);
_activeHandoff = handoff;
handoff.activateDestination = () {
final destination = handoff.destinationController;
if (destination != null) logic.pages = destination;
};
_logFullPlayerRoute('present');
setState(() => _presentationActive = true);
final route = Navigator.of(context).push<void>(
@@ -1251,41 +1213,24 @@ class _PlayerPageState extends State<PlayerPage>
setState(() => _presentationSuspended = true);
await WidgetsBinding.instance.endOfFrame;
}
sourcePageController.dispose();
handoff.activateDestination?.call();
await route;
if (!mounted) return;
final currentPage = logic.visibleIndices.indexOf(
logic.session.currentIndex,
);
final restoredPage =
_returnPageController ??
PageController(initialPage: currentPage < 0 ? 0 : currentPage);
logic.pages = restoredPage;
_pageController = restoredPage;
logic.attach(widget, isMounted: () => mounted);
setState(() {
_presentationActive = false;
_presentationSuspended = false;
_presentationReturning = false;
});
_activeHandoff = null;
logic.session.setRouteVisible(widget.active);
await logic.resumeAfterFullPlayer();
if (!mounted) return;
_logFullPlayerRoute('restored');
}
Future<void> _prepareSourceAfterFullPlayer() async {
if (!mounted) return;
final handoff = _activeHandoff;
if (handoff == null) return;
logic.attach(widget, isMounted: () => mounted);
logic.restoreFeedHandoff(
page: widget,
sourceQueue: handoff.sourceQueue,
sourceEpisodes: handoff.sourceEpisodes,
sourceIndex: handoff.sourceIndex,
);
_returnPageController = PageController(initialPage: handoff.sourceIndex);
await logic.prepareFeedResume();
if (!mounted) return;
setState(() {
_presentationSuspended = false;
_presentationReturning = true;
@@ -1296,6 +1241,7 @@ class _PlayerPageState extends State<PlayerPage>
Future<void> _leaveHandoff(BuildContext context) async {
if (_leavingHandoff) return;
setState(() => _leavingHandoff = true);
await logic.session.pauseForRouteHandoff();
await widget.handoff?.prepareSource();
if (context.mounted) Navigator.of(context).pop();
}
+27
View File
@@ -32,6 +32,33 @@ void main() {
await session.close();
});
test('should_keep_only_current_player_when_neighbor_warmup_disabled', () async {
final factory = FakeFactory();
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (episode) => episode.isFree,
warmNeighborsEnabled: false,
);
await session.select(0, preloadIndex: 1);
await session.preloadSettled;
expect(factory.created, hasLength(1));
expect(factory.live, 1);
expect(session.preloadedIndex, isNull);
final first = factory.created.single;
await session.select(1, preloadIndex: 2, previousIndex: 0);
await session.preloadSettled;
expect(first.disposed, isTrue);
expect(factory.created, hasLength(2));
expect(factory.live, 1);
expect(session.livePlayerCount, 1);
expect(session.active, same(factory.created.last));
await session.close();
});
test('should_release_previous_player_immediately_when_selecting_locked', () async {
final lockedEpisodes = [
Episode(
+131 -159
View File
@@ -20,9 +20,9 @@ import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/video_player_port.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart';
import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart';
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
import 'package:video_player/video_player.dart';
@@ -86,7 +86,7 @@ void main() {
_expectPlayerNavigationBar(tester);
});
testWidgets('should_preserve_recommend_feed_when_full_player_pops', (
testWidgets('should_keep_feed_session_unchanged_when_switching_episode', (
tester,
) async {
final factory = _RouteVideoFactory();
@@ -95,85 +95,86 @@ void main() {
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
final feedPage = find
.descendant(
of: find.byKey(const Key('recommend-feed-player')),
matching: find.byType(PageView),
)
.first;
await tester.drag(feedPage, const Offset(0, -620));
await tester.pumpAndSettle();
await tester.drag(feedPage, const Offset(0, -620));
await tester.pumpAndSettle();
final recommendPlayer = find.byKey(const Key('recommend-feed-player'));
final renderStack = find.descendant(
of: recommendPlayer,
matching: find.byType(PlayerRenderStack),
final sourceState = tester.state(recommendPlayer);
final sourceLogic = _pageLogic(tester, recommendPlayer);
final sourceIndex = sourceLogic.session.currentIndex;
final sourceDramaId = sourceLogic.currentDramaId;
final sourceEpisodeId = sourceLogic.session.currentEpisode.id;
final feedPlayer = sourceLogic.session.active! as _RouteVideoPlayer;
final feedPlayCalls = feedPlayer.playCalls;
expect(sourceDramaId, 'vengeance-demo');
expect(sourceIndex, 2);
expect(sourceLogic.session.livePlayerCount, 1);
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle();
final fullPlayer = find.byType(PlayerPage);
final fullLogic = _pageLogic(tester, fullPlayer);
expect(fullLogic.session, isNot(same(sourceLogic.session)));
expect(feedPlayer.disposed, isFalse);
expect(feedPlayer.playing, isFalse);
for (var index = 0; index < 4; index++) {
await tester.drag(
find.byKey(const Key('player-video-tap')),
const Offset(0, -560),
);
await tester.pumpAndSettle();
}
expect(fullLogic.session.currentEpisode.number, 5);
expect(
fullLogic.queue.entryAt(fullLogic.session.currentIndex).drama.id,
sourceDramaId,
);
expect(recommendPlayer, findsOneWidget);
expect(renderStack, findsOneWidget);
final playerState = tester.state(recommendPlayer);
expect(fullLogic.session.userWantsPlay, isTrue);
expect(sourceLogic.session.currentIndex, sourceIndex);
expect(sourceLogic.currentDramaId, sourceDramaId);
expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId);
expect(sourceLogic.session.active, same(feedPlayer));
expect(feedPlayer.disposed, isFalse);
final requestsBeforeReturn = playback.requests.length;
await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle();
final currentSurface = find.descendant(
of: recommendPlayer,
matching: find.byKey(const ValueKey('player-render-slot-current')),
);
final sourceRender = tester.widget<PlayerRenderStack>(renderStack);
final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index;
final currentEpisodeId = factory.episodes.first.id;
final controller = factory.created.first.controller;
final requestsBefore = playback.requests.length;
expect(controller.value.position, const Duration(seconds: 5));
expect(
find.descendant(
of: currentSurface,
matching: find.byKey(const Key('player-single-placeholder')),
),
findsNothing,
);
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle();
expect(find.byType(PlayerPage), findsOneWidget);
final fullPlayer = tester.widget<PlayerPage>(find.byType(PlayerPage));
expect(fullPlayer.homeMode, isFalse);
expect(fullPlayer.handoff, isNotNull);
expect(fullPlayer.handoff!.sourceIndex, sourceIndex);
expect(
fullPlayer.handoff!.logic.session.currentIndex,
fullPlayer.handoff!.episodeIndex,
);
expect(find.byKey(const Key('home-tabs-bar')), findsNothing);
expect(find.byKey(const Key('recommend-top-nav')), findsNothing);
expect(find.byKey(const Key('home-feed-gesture')), findsNothing);
expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(
factory.episodes.where((episode) => episode.id == currentEpisodeId),
hasLength(1),
);
expect(playback.requests.length, requestsBefore);
expect(controller.value.position, const Duration(seconds: 5));
final fullCurrentSurface = find.descendant(
of: find.byType(PlayerPage),
matching: find.byKey(const ValueKey('player-render-slot-current')),
);
expect(
find.descendant(
of: fullCurrentSurface,
matching: find.byKey(const Key('player-single-placeholder')),
),
findsNothing,
);
expect(
tester.widget<PlayerVideoSurface>(fullCurrentSurface).controller,
same(controller),
);
await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-page')), findsOneWidget);
expect(recommendPlayer, findsOneWidget);
expect(renderStack, findsOneWidget);
expect(
factory.episodes.where((episode) => episode.id == currentEpisodeId),
hasLength(1),
);
expect(playback.requests.length, requestsBefore);
expect(controller.value.position, const Duration(seconds: 5));
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget);
expect(tester.state(recommendPlayer), same(playerState));
expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing);
expect(tester.state(recommendPlayer), same(sourceState));
expect(sourceLogic.session.currentIndex, sourceIndex);
expect(sourceLogic.currentDramaId, sourceDramaId);
expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId);
expect(sourceLogic.session.active, same(feedPlayer));
expect(feedPlayer.disposed, isFalse);
expect(sourceLogic.active, isTrue);
expect(sourceLogic.session.userWantsPlay, isTrue);
expect(sourceLogic.session.canResumeAfterBackground, isTrue);
expect(sourceLogic.session.canAutoAdvance, isTrue);
expect(sourceLogic.session.isLocked, isFalse);
expect(sourceLogic.session.loading, isFalse);
expect(sourceLogic.session.activeSlotActivated, isTrue);
expect(sourceLogic.session.activeSlotPlaying, isTrue);
expect(feedPlayer.seekCalls, 1);
expect(feedPlayer.playCalls, greaterThan(feedPlayCalls));
expect(playback.requests.length, requestsBeforeReturn);
expect(feedPlayer.controller.value.position, Duration.zero);
expect(feedPlayer.playing, isTrue);
expect(
find.descendant(
of: currentSurface,
@@ -183,72 +184,10 @@ void main() {
);
expect(
tester.widget<PlayerVideoSurface>(currentSurface).controller,
same(controller),
);
});
testWidgets('should_swipe_full_player_without_creating_another_session', (
tester,
) async {
final factory = _RouteVideoFactory();
await _pumpApp(tester, playerFactory: factory.create);
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle();
final fullPlayer = tester.widget<PlayerPage>(find.byType(PlayerPage));
final handoff = fullPlayer.handoff!;
final dramaId = handoff.seriesDrama.id;
final firstEpisodeId = handoff.logic.session.currentEpisode.id;
final createdBefore = factory.created.length;
await tester.drag(
find.byKey(const Key('player-video-tap')),
const Offset(0, -560),
);
await tester.pumpAndSettle();
expect(find.byType(PlayerPage), findsOneWidget);
expect(factory.created.length, lessThanOrEqualTo(createdBefore + 1));
expect(find.byKey(const Key('player-render-stack')), findsOneWidget);
final switchedEpisode = handoff.logic.session.currentEpisode;
expect(switchedEpisode.id, isNot(firstEpisodeId));
expect(switchedEpisode.number, 2);
expect(
handoff.logic.queue.entryAt(handoff.logic.session.currentIndex).drama.id,
dramaId,
);
expect(handoff.logic.session.userWantsPlay, isTrue);
expect(
factory.episodes.where((episode) => episode.id == switchedEpisode.id),
hasLength(1),
);
final switchedVideo = handoff.logic.session.active! as VideoPlayerPort;
final switchedController = switchedVideo.controller;
await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle();
expect(handoff.logic.session.currentIndex, handoff.sourceIndex);
expect(
handoff.logic.queue.entryAt(handoff.sourceIndex).episode.id,
switchedEpisode.id,
);
final sourceCurrentSurface = find.descendant(
of: find.byType(PlayerPage),
matching: find.byKey(const ValueKey('player-render-slot-current')),
);
expect(
tester.widget<PlayerVideoSurface>(sourceCurrentSurface).controller,
same(switchedController),
);
expect(switchedController.value.position, const Duration(seconds: 5));
expect(
find.descendant(
of: sourceCurrentSurface,
matching: find.byKey(const Key('player-single-placeholder')),
),
findsNothing,
same(feedPlayer.controller),
);
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget);
});
testWidgets('should_show_one_paused_overlay_when_entering_full_player', (
@@ -279,7 +218,8 @@ void main() {
});
testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async {
await _pumpApp(tester);
final factory = _RouteVideoFactory();
await _pumpApp(tester, playerFactory: factory.create);
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
@@ -291,33 +231,34 @@ void main() {
.first;
await tester.drag(feedPage, const Offset(0, -620));
await tester.pumpAndSettle();
final sourceRender = tester.widget<PlayerRenderStack>(
find.byType(PlayerRenderStack),
);
final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index;
expect(sourceIndex, isNotNull);
expect(sourceIndex, greaterThan(0));
final feedIndex = sourceIndex!;
final recommendPlayer = find.byKey(const Key('recommend-feed-player'));
final sourceLogic = _pageLogic(tester, recommendPlayer);
final feedIndex = sourceLogic.session.currentIndex;
final feedEpisodeId = sourceLogic.session.currentEpisode.id;
final feedDramaId = sourceLogic.currentDramaId;
expect(feedIndex, 1);
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle();
final fullPlayer = tester.widget<PlayerPage>(find.byType(PlayerPage));
final handoff = fullPlayer.handoff!;
expect(handoff.sourceIndex, feedIndex);
expect(handoff.episodeIndex, 0);
expect(handoff.logic.session.currentIndex, handoff.episodeIndex);
expect(
handoff.seriesDrama.id,
handoff.sourceQueue.entryAt(feedIndex).drama.id,
);
expect(
handoff.logic.session.currentEpisode.id,
handoff.sourceEpisodes[feedIndex].id,
final fullLogic = _pageLogic(tester, find.byType(PlayerPage));
await tester.drag(
find.byKey(const Key('player-video-tap')),
const Offset(0, -560),
);
await tester.pumpAndSettle();
expect(fullLogic.session.currentIndex, 1);
expect(fullLogic.session.currentEpisode.number, 2);
expect(fullLogic.queue.entryAt(1).drama.id, feedDramaId);
expect(sourceLogic.session.currentIndex, feedIndex);
expect(sourceLogic.session.currentEpisode.id, feedEpisodeId);
expect(sourceLogic.currentDramaId, feedDramaId);
await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle();
expect(sourceLogic.session.currentIndex, feedIndex);
expect(sourceLogic.session.currentEpisode.id, feedEpisodeId);
});
testWidgets('should_render_player_loading_indicators_at_compact_size', (
@@ -1682,6 +1623,11 @@ Future<void> _pumpApp(
if (settle) await tester.pumpAndSettle();
}
PlayerLogic _pageLogic(WidgetTester tester, Finder page) {
final dynamic state = tester.state(page);
return state.logic as PlayerLogic;
}
class _RouteVideoFactory {
final List<_RouteVideoPlayer> created = [];
final List<Episode> episodes = [];
@@ -1697,6 +1643,12 @@ class _RouteVideoFactory {
class _RouteVideoPlayer extends VideoPlayerPort {
_RouteVideoPlayer(super.episode);
bool disposed = false;
int seekCalls = 0;
int playCalls = 0;
bool get playing => controller.value.isPlaying;
@override
Future<void> initialize() async {
controller.value = const VideoPlayerValue(
@@ -1710,6 +1662,7 @@ class _RouteVideoPlayer extends VideoPlayerPort {
@override
Future<void> play() async {
playCalls++;
final value = controller.value;
controller.value = VideoPlayerValue(
duration: value.duration,
@@ -1731,6 +1684,25 @@ class _RouteVideoPlayer extends VideoPlayerPort {
isPlaying: false,
);
}
@override
Future<void> seekTo(Duration position) async {
seekCalls++;
final value = controller.value;
controller.value = VideoPlayerValue(
duration: value.duration,
size: value.size,
position: position,
isInitialized: value.isInitialized,
isPlaying: value.isPlaying,
);
}
@override
Future<void> dispose() async {
disposed = true;
await super.dispose();
}
}
class _RecordingPlayer implements PlayerPort {