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();
}