fix(player): 复用播放会话进入全屏

This commit is contained in:
Zhou Weidong committed 2026-10-26 15:55:46 +08:00
1 parent ab4c246675
commit 23e80dd637
4 files changed
+303 -77

No files matched your search

+177 -63
View File
@@ -128,6 +128,7 @@ class _PlayerPageState extends State<PlayerPage>
DateTime? _lastDoubleTapAt;
DateTime? _swipeStartedAt;
bool _swipePrewarmCaptured = false;
bool _fullPlayerPresented = false;
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
@@ -257,11 +258,13 @@ class _PlayerPageState extends State<PlayerPage>
@override
void didPushNext() {
if (_fullPlayerPresented) return;
logic.onRouteCovered();
}
@override
void didPopNext() {
if (_fullPlayerPresented) return;
logic.onRouteRevealed();
}
@@ -930,7 +933,7 @@ class _PlayerPageState extends State<PlayerPage>
),
if (logic.pip || logic.pipPreparing)
const SizedBox.expand()
else if (widget.homeMode)
else if (widget.homeMode && !_fullPlayerPresented)
HomeFeedOverlay(
l10n: l10n,
drama: drama,
@@ -959,10 +962,8 @@ class _PlayerPageState extends State<PlayerPage>
onComments: () => unawaited(_openComments(context, drama, episode)),
onShare: () => logic.shareDrama(context, drama),
onDetail: () => unawaited(logic.openDetail(context)),
onOpenFullPlayer: () async {
await widget.onOpenFullPlayer?.call(drama, episode);
if (!mounted || !widget.active) return;
logic.session.setUserWantsPlay(true);
onOpenFullPlayer: () {
unawaited(_presentFullPlayer(context, index));
},
onEpisodes: () => logic.showEpisodes(context),
onMore: _openCurrentMore,
@@ -978,64 +979,8 @@ class _PlayerPageState extends State<PlayerPage>
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
)
else
PlayerPlaybackOverlay(
data: PlayerOverlayData(
l10n: l10n,
drama: drama,
episode: episode,
controller: videoReady ? videoPort!.controller : null,
locked: locked,
liked: interactions.isEpisodeLiked(episode.id),
likeCount:
interactions.episodeLikeCount(episode.id) ??
episode.likeCount,
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
saved: context.watch<FavoriteController>().isSaved(drama.id),
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
totalEpisodes: logic.visibleIndices.length,
boosting: _boosting,
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
),
scrubbing: _scrubbing,
actions: PlayerOverlayActions(
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onTogglePlay: _togglePlayback,
onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true),
onDetail: () => unawaited(logic.openDetail(context)),
onSpeed: () => unawaited(_openSpeedSheet(context)),
onEpisodes: () => logic.showEpisodes(context),
onComments: () =>
unawaited(_openComments(context, drama, episode)),
onLike: () =>
unawaited(_toggleLike(context, interactions, episode)),
onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, episode),
onScrubStart: videoReady
? () {
_setScrubbing(true);
logic.beginScrub(videoPort!);
}
: null,
onScrubEnd: () => _setScrubbing(false),
onSeek: videoReady
? (position) => logic.seekTo(context, videoPort!, position)
: null,
onLongPressCenter: () => _openMore(context, episode),
onBoostStart: _startBoost,
onBoostEnd: _endBoost,
),
controlsVisible: _chromeVisible,
),
else if (!widget.homeMode)
_standardPlaybackOverlay(context, index, interactions),
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
if (selected && !theater && locked)
PlayerBlockedNotice(
@@ -1075,4 +1020,173 @@ class _PlayerPageState extends State<PlayerPage>
],
);
}
Widget _standardPlaybackOverlay(
BuildContext context,
int index,
InteractionController interactions, {
bool presentation = false,
}) {
if (logic.pip || logic.pipPreparing) return const SizedBox.expand();
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = _resolvedEpisode(index);
final selected = index == logic.session.currentIndex;
final locked =
logic.isEpisodeLocked(episode) ||
logic.session.isLocked ||
logic.blockedEpisodeId == episode.id;
final port = locked
? null
: (selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final videoReady = videoPort?.controller.value.isInitialized ?? false;
return PlayerPlaybackOverlay(
data: PlayerOverlayData(
l10n: l10n,
drama: drama,
episode: episode,
controller: videoReady ? videoPort!.controller : null,
locked: locked,
liked: interactions.isEpisodeLiked(episode.id),
likeCount:
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
saved: context.watch<FavoriteController>().isSaved(drama.id),
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
totalEpisodes: logic.visibleIndices.length,
boosting: _boosting,
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
),
scrubbing: _scrubbing,
actions: PlayerOverlayActions(
onBack: presentation
? () => Navigator.of(context).pop()
: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onTogglePlay: _togglePlayback,
onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true),
onDetail: () => unawaited(logic.openDetail(context)),
onSpeed: () => unawaited(_openSpeedSheet(context)),
onEpisodes: () => logic.showEpisodes(context),
onComments: () => unawaited(_openComments(context, drama, episode)),
onLike: () => unawaited(_toggleLike(context, interactions, episode)),
onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, episode),
onScrubStart: videoReady
? () {
_setScrubbing(true);
logic.beginScrub(videoPort!);
}
: null,
onScrubEnd: () => _setScrubbing(false),
onSeek: videoReady
? (position) => logic.seekTo(context, videoPort!, position)
: null,
onLongPressCenter: () => _openMore(context, episode),
onBoostStart: _startBoost,
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>();
_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,
);
},
),
),
),
),
transitionsBuilder: (context, animation, _, child) =>
FadeTransition(opacity: animation, child: child),
),
);
if (!mounted) return;
setState(() => _fullPlayerPresented = false);
_logFullPlayerRoute('restored');
}
void _logFullPlayerRoute(String phase) {
if (!kDebugMode) return;
final active = logic.session.active;
final video = active is VideoPlayerPort ? active : null;
debugPrint(
'[player-route] ts=${DateTime.now().millisecondsSinceEpoch} '
'phase=$phase '
'episode=${logic.session.currentEpisode.id} '
'controller=${video == null ? '-' : identityHashCode(video.controller)} '
'position=${video?.controller.value.position.inMilliseconds ?? '-'}',
);
}
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,6 +36,8 @@ class PlayerPlaybackOverlay extends StatelessWidget {
required this.actions,
required this.controlsVisible,
required this.scrubbing,
this.onVerticalDragUpdate,
this.onVerticalDragEnd,
});
/// 展示数据。
@@ -49,6 +51,8 @@ class PlayerPlaybackOverlay extends StatelessWidget {
/// 正在拖动进度条。拖动期间清屏,只留进度条。
final bool scrubbing;
final GestureDragUpdateCallback? onVerticalDragUpdate;
final GestureDragEndCallback? onVerticalDragEnd;
@override
Widget build(BuildContext context) {
@@ -65,6 +69,8 @@ class PlayerPlaybackOverlay extends StatelessWidget {
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => actions.onBoostEnd(),
onLongPressCancel: actions.onBoostEnd,
onVerticalDragUpdate: onVerticalDragUpdate,
onVerticalDragEnd: onVerticalDragEnd,
),
),
),