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,
),
),
),
+112 -6
View File
@@ -17,12 +17,14 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
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_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/seek_progress_bar.dart';
import 'package:video_player/video_player.dart';
import '../support/fake_catalog.dart';
import '../support/fake_category.dart';
@@ -86,7 +88,9 @@ void main() {
testWidgets('should_preserve_recommend_feed_when_full_player_pops', (
tester,
) async {
await _pumpApp(tester);
final factory = _RouteVideoFactory();
final playback = FakePlaybackRepository();
await _pumpApp(tester, playerFactory: factory.create, playback: playback);
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
@@ -101,27 +105,80 @@ void main() {
expect(renderStack, findsOneWidget);
final playerState = tester.state(recommendPlayer);
final renderState = tester.state(renderStack);
final currentSurface = find.descendant(
of: recommendPlayer,
matching: find.byKey(
const ValueKey('player-render-slot-current'),
),
);
final controller = factory.created.first.controller;
final createdBefore = factory.created.length;
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);
expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(factory.created.length, createdBefore);
expect(playback.requests.length, requestsBefore);
expect(controller.value.position, const Duration(seconds: 5));
expect(
tester.widget<PlayerPage>(find.byType(PlayerPage).last).homeMode,
isFalse,
find.descendant(
of: currentSurface,
matching: find.byKey(const Key('player-single-placeholder')),
),
findsNothing,
);
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pump();
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-page')), findsOneWidget);
expect(recommendPlayer, findsOneWidget);
expect(renderStack, findsOneWidget);
expect(factory.created.length, createdBefore);
expect(playback.requests.length, requestsBefore);
expect(controller.value.position, const Duration(seconds: 5));
expect(tester.state(recommendPlayer), same(playerState));
expect(tester.state(renderStack), same(renderState));
expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing);
expect(
find.descendant(
of: currentSurface,
matching: find.byKey(const Key('player-single-placeholder')),
),
findsNothing,
);
});
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();
expect(tester.state(recommendPlayer), same(playerState));
expect(tester.state(renderStack), same(renderState));
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle();
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);
});
testWidgets('should_render_player_loading_indicators_at_compact_size', (
@@ -1482,6 +1539,55 @@ Future<void> _pumpApp(
if (settle) await tester.pumpAndSettle();
}
class _RouteVideoFactory {
final List<_RouteVideoPlayer> created = [];
PlayerPort create(Episode episode) {
final player = _RouteVideoPlayer(episode);
created.add(player);
return player;
}
}
class _RouteVideoPlayer extends VideoPlayerPort {
_RouteVideoPlayer(super.episode);
@override
Future<void> initialize() async {
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration(seconds: 5),
isInitialized: true,
isPlaying: true,
);
}
@override
Future<void> play() async {
final value = controller.value;
controller.value = VideoPlayerValue(
duration: value.duration,
size: value.size,
position: value.position,
isInitialized: value.isInitialized,
isPlaying: true,
);
}
@override
Future<void> pause() async {
final value = controller.value;
controller.value = VideoPlayerValue(
duration: value.duration,
size: value.size,
position: value.position,
isInitialized: value.isInitialized,
isPlaying: false,
);
}
}
class _RecordingPlayer implements PlayerPort {
bool playing = false;
+8 -8
View File
@@ -464,28 +464,28 @@ void main() {
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
});
testWidgets('should_open_standalone_player_from_title_and_watch_all', (
testWidgets('should_open_full_player_presentation_from_title_and_watch_all', (
tester,
) async {
await _pumpRecommend(tester);
await tester.tap(find.byKey(const Key('home-feed-series-name')));
await tester.pumpAndSettle();
expect(find.byType(PlayerPage), findsOneWidget);
expect(
tester.widget<PlayerPage>(find.byType(PlayerPage).last).homeMode,
isFalse,
tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode,
isTrue,
);
await tester.binding.handlePopRoute();
expect(find.byKey(const Key('player-back')), findsOneWidget);
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-page')), findsOneWidget);
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle();
expect(
tester.widget<PlayerPage>(find.byType(PlayerPage).last).homeMode,
isFalse,
);
expect(find.byType(PlayerPage), findsOneWidget);
expect(find.byKey(const Key('player-back')), findsOneWidget);
});
testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', (