fix(player): 分离首页与播放页播放上下文
This commit is contained in:
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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user