fix(player): 独立全屏路由复用播放会话
This commit is contained in:
1 parent
23e80dd637
commit
9d2d3b97a1
4 files changed
+219
-103
No files matched your search
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user