fix(player): 复用播放会话进入全屏
This commit is contained in:
1 parent
ab4c246675
commit
23e80dd637
4 files changed
+303
-77
No files matched your search
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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', (
|
||||
|
||||
Reference in new issue
Block a user