fix(player): 独立全屏路由复用播放会话

This commit is contained in:
Zhou Weidong committed 2026-10-26 16:08:15 +08:00
1 parent 23e80dd637
commit 9d2d3b97a1
4 files changed
+219 -103

No files matched your search

+150 -91
View File
@@ -55,6 +55,20 @@ const double _playerLoadingSize = 24;
const double _playerLoadingStrokeWidth = 2;
const double _feedRefreshTopGap = 56;
class PlayerPageHandoff {
PlayerPageHandoff({
required this.logic,
required this.initialIndex,
required this.prepareSource,
});
final PlayerLogic logic;
final int initialIndex;
final Future<void> Function() prepareSource;
PageController? destinationController;
VoidCallback? activateDestination;
}
class PlayerPage extends StatefulWidget {
const PlayerPage({
super.key,
@@ -82,6 +96,7 @@ class PlayerPage extends StatefulWidget {
this.onChannelSelected,
this.wallet,
this.downloads,
this.handoff,
});
final Drama drama;
@@ -108,6 +123,7 @@ class PlayerPage extends StatefulWidget {
final ValueChanged<HomeFeedChannel>? onChannelSelected;
final WalletController? wallet;
final DownloadController? downloads;
final PlayerPageHandoff? handoff;
@override
State<PlayerPage> createState() => _PlayerPageState();
@@ -116,6 +132,8 @@ 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;
PageRoute<dynamic>? _subscribedRoute;
@@ -128,7 +146,11 @@ class _PlayerPageState extends State<PlayerPage>
DateTime? _lastDoubleTapAt;
DateTime? _swipeStartedAt;
bool _swipePrewarmCaptured = false;
bool _fullPlayerPresented = false;
bool _presentationActive = false;
bool _presentationSuspended = false;
bool _presentationReturning = false;
bool _leavingHandoff = false;
PageController? _returnPageController;
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
@@ -138,13 +160,17 @@ class _PlayerPageState extends State<PlayerPage>
@override
void initState() {
super.initState();
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(),
wallet: widget.wallet,
downloads: widget.downloads,
);
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.attach(widget, isMounted: () => mounted);
_thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource,
@@ -152,10 +178,20 @@ class _PlayerPageState extends State<PlayerPage>
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
..addListener(_handleSpriteFrameChanged);
WidgetsBinding.instance.addObserver(this);
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: context.read<PlaybackCoordinator>(),
);
if (handoff == null) {
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: context.read<PlaybackCoordinator>(),
);
_pageController = logic.pages;
} else {
final position = logic.visibleIndices.indexOf(handoff.initialIndex);
_pageController = PageController(
initialPage: position < 0 ? 0 : position,
);
handoff.destinationController = _pageController;
logic.session.setRouteVisible(widget.active);
}
}
@override
@@ -258,13 +294,13 @@ class _PlayerPageState extends State<PlayerPage>
@override
void didPushNext() {
if (_fullPlayerPresented) return;
if (_presentationActive) return;
logic.onRouteCovered();
}
@override
void didPopNext() {
if (_fullPlayerPresented) return;
if (_presentationActive) return;
logic.onRouteRevealed();
}
@@ -276,7 +312,11 @@ class _PlayerPageState extends State<PlayerPage>
..removeListener(_handleSpriteFrameChanged)
..dispose();
_thumbnailService.dispose();
logic.dispose();
if (_ownsLogic) {
logic.dispose();
} else {
_pageController.dispose();
}
super.dispose();
}
@@ -513,6 +553,8 @@ class _PlayerPageState extends State<PlayerPage>
}
Widget _buildPlayer(BuildContext context) {
if (_presentationSuspended) return const SizedBox.expand();
if (_presentationReturning) return _buildPresentationReturnLayer();
final interactions = context.watch<InteractionController>();
final l10n = AppLocalizations.of(context)!;
final indices = logic.visibleIndices;
@@ -523,7 +565,7 @@ class _PlayerPageState extends State<PlayerPage>
final pages = NotificationListener<ScrollNotification>(
onNotification: _handlePageScroll,
child: PageView.builder(
controller: logic.pages,
controller: _pageController,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
physics: widget.onRefreshFeed == null
@@ -563,7 +605,7 @@ class _PlayerPageState extends State<PlayerPage>
final pageLayer = widget.onRefreshFeed == null
? pages
: FeedPullRefresh(
pageController: logic.pages,
pageController: _pageController,
topInset:
MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
onRefresh: _refreshHomeFeed,
@@ -573,7 +615,7 @@ class _PlayerPageState extends State<PlayerPage>
fit: StackFit.expand,
children: [
PlayerRenderStack(
pageController: logic.pages,
pageController: _pageController,
slots: _renderSlots(indices),
),
pageLayer,
@@ -620,7 +662,7 @@ class _PlayerPageState extends State<PlayerPage>
),
],
);
return AnnotatedRegion<SystemUiOverlayStyle>(
final player = AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('player-system-overlay'),
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: AppColors.transparent,
@@ -641,6 +683,14 @@ class _PlayerPageState extends State<PlayerPage>
),
),
);
if (widget.handoff == null) return player;
return PopScope<void>(
canPop: _leavingHandoff,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_leaveHandoff(context));
},
child: player,
);
}
void _openCurrentMore() {
@@ -701,6 +751,18 @@ 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,
slots: _renderSlots(indices),
),
);
}
PlayerRenderSlotData _renderSlot(
PlayerRenderSlotRole role,
int? index,
@@ -933,7 +995,7 @@ class _PlayerPageState extends State<PlayerPage>
),
if (logic.pip || logic.pipPreparing)
const SizedBox.expand()
else if (widget.homeMode && !_fullPlayerPresented)
else if (widget.homeMode && !_presentationActive)
HomeFeedOverlay(
l10n: l10n,
drama: drama,
@@ -1024,9 +1086,8 @@ class _PlayerPageState extends State<PlayerPage>
Widget _standardPlaybackOverlay(
BuildContext context,
int index,
InteractionController interactions, {
bool presentation = false,
}) {
InteractionController interactions,
) {
if (logic.pip || logic.pipPreparing) return const SizedBox.expand();
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
@@ -1067,8 +1128,8 @@ class _PlayerPageState extends State<PlayerPage>
),
scrubbing: _scrubbing,
actions: PlayerOverlayActions(
onBack: presentation
? () => Navigator.of(context).pop()
onBack: widget.handoff != null
? () => unawaited(_leaveHandoff(context))
: widget.embedded
? null
: () => unawaited(logic.leave(context)),
@@ -1098,56 +1159,82 @@ class _PlayerPageState extends State<PlayerPage>
onBoostEnd: _endBoost,
),
controlsVisible: _chromeVisible,
onVerticalDragUpdate: presentation
? _handleFullPlayerDragUpdate
: null,
onVerticalDragEnd: presentation ? _handleFullPlayerDragEnd : null,
);
}
Future<void> _presentFullPlayer(BuildContext context, int index) async {
if (_fullPlayerPresented || !logic.pages.hasClients) return;
// The route owns presentation only; PlaybackSession keeps the controller.
final interactions = context.read<InteractionController>();
if (_presentationActive || !_pageController.hasClients) return;
final sourcePageController = _pageController;
final handoff = PlayerPageHandoff(
logic: logic,
initialIndex: index,
prepareSource: _prepareSourceAfterFullPlayer,
);
handoff.activateDestination = () {
final destination = handoff.destinationController;
if (destination != null) logic.pages = destination;
};
_logFullPlayerRoute('present');
setState(() => _fullPlayerPresented = true);
await Navigator.of(context).push<void>(
PageRouteBuilder<void>(
opaque: false,
barrierColor: null,
barrierDismissible: false,
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder: (routeContext, _, _) => Material(
color: AppColors.transparent,
child: Scaffold(
backgroundColor: AppColors.transparent,
body: ListenableBuilder(
listenable: logic,
builder: (context, _) => AnimatedBuilder(
animation: logic.session,
builder: (context, _) {
final current = logic.session.currentIndex;
return _standardPlaybackOverlay(
routeContext,
current,
interactions,
presentation: true,
);
},
),
),
),
setState(() => _presentationActive = true);
final route = Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(
drama: logic.queue.entryAt(index).drama,
initialIndex: index,
handoff: handoff,
wallet: logic.wallet,
downloads: logic.downloads,
),
transitionsBuilder: (context, animation, _, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await WidgetsBinding.instance.endOfFrame;
if (mounted) {
setState(() => _presentationSuspended = true);
await WidgetsBinding.instance.endOfFrame;
}
sourcePageController.dispose();
handoff.activateDestination?.call();
await route;
if (!mounted) return;
setState(() => _fullPlayerPresented = false);
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;
});
logic.session.setRouteVisible(widget.active);
_logFullPlayerRoute('restored');
}
Future<void> _prepareSourceAfterFullPlayer() async {
if (!mounted) return;
final page = logic.visibleIndices.indexOf(logic.session.currentIndex);
_returnPageController = PageController(
initialPage: page < 0 ? 0 : page,
);
logic.attach(widget, isMounted: () => mounted);
setState(() {
_presentationSuspended = false;
_presentationReturning = true;
});
await WidgetsBinding.instance.endOfFrame;
}
Future<void> _leaveHandoff(BuildContext context) async {
if (_leavingHandoff) return;
setState(() => _leavingHandoff = true);
await widget.handoff?.prepareSource();
if (context.mounted) Navigator.of(context).pop();
}
void _logFullPlayerRoute(String phase) {
if (!kDebugMode) return;
final active = logic.session.active;
@@ -1161,32 +1248,4 @@ class _PlayerPageState extends State<PlayerPage>
);
}
void _handleFullPlayerDragUpdate(DragUpdateDetails details) {
if (!logic.pages.hasClients) return;
final position = logic.pages.position;
final next = (position.pixels - details.delta.dy).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if ((next - position.pixels).abs() < 0.5) return;
position.jumpTo(next);
}
void _handleFullPlayerDragEnd(DragEndDetails details) {
if (!logic.pages.hasClients || logic.visibleIndices.isEmpty) return;
final position = logic.pages.position;
final page = position.pixels / position.viewportDimension;
final velocity = details.primaryVelocity ?? 0;
var target = page.round();
if (velocity < -260) target = page.floor() + 1;
if (velocity > 260) target = page.ceil() - 1;
target = target.clamp(0, logic.visibleIndices.length - 1);
unawaited(
logic.pages.animateToPage(
target,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
),
);
}
}
@@ -36,8 +36,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
required this.actions,
required this.controlsVisible,
required this.scrubbing,
this.onVerticalDragUpdate,
this.onVerticalDragEnd,
});
/// 展示数据。
@@ -51,8 +49,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
/// 正在拖动进度条。拖动期间清屏,只留进度条。
final bool scrubbing;
final GestureDragUpdateCallback? onVerticalDragUpdate;
final GestureDragEndCallback? onVerticalDragEnd;
@override
Widget build(BuildContext context) {
@@ -69,8 +65,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => actions.onBoostEnd(),
onLongPressCancel: actions.onBoostEnd,
onVerticalDragUpdate: onVerticalDragUpdate,
onVerticalDragEnd: onVerticalDragEnd,
),
),
),