fix(player): 改为显式清屏交互

This commit is contained in:
Zhou Weidong committed 2026-10-19 16:43:50 +08:00
1 parent f4e1a5424e
commit f613033088
12 files changed
+158 -166

No files matched your search

+1
View File
@@ -46,6 +46,7 @@
"total": {"type": "int"} "total": {"type": "int"}
} }
}, },
"clearScreen": "Clear screen",
"minutesAgo": "{count} min ago", "minutesAgo": "{count} min ago",
"@minutesAgo": { "@minutesAgo": {
"placeholders": { "placeholders": {
+1
View File
@@ -46,6 +46,7 @@
"total": {"type": "int"} "total": {"type": "int"}
} }
}, },
"clearScreen": "Bersihkan layar",
"minutesAgo": "{count} mnt lalu", "minutesAgo": "{count} mnt lalu",
"@minutesAgo": { "@minutesAgo": {
"placeholders": { "placeholders": {
+6
View File
@@ -340,6 +340,12 @@ abstract class AppLocalizations {
/// **'Episode {current} / {total}'** /// **'Episode {current} / {total}'**
String episodeOfTotal(int current, int total); String episodeOfTotal(int current, int total);
/// No description provided for @clearScreen.
///
/// In en, this message translates to:
/// **'Clear screen'**
String get clearScreen;
/// No description provided for @minutesAgo. /// No description provided for @minutesAgo.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+3
View File
@@ -133,6 +133,9 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Episode $current / $total'; return 'Episode $current / $total';
} }
@override
String get clearScreen => 'Clear screen';
@override @override
String minutesAgo(int count) { String minutesAgo(int count) {
return '$count min ago'; return '$count min ago';
+3
View File
@@ -133,6 +133,9 @@ class AppLocalizationsId extends AppLocalizations {
return 'Episode $current / $total'; return 'Episode $current / $total';
} }
@override
String get clearScreen => 'Bersihkan layar';
@override @override
String minutesAgo(int count) { String minutesAgo(int count) {
return '$count mnt lalu'; return '$count mnt lalu';
+3
View File
@@ -130,6 +130,9 @@ class AppLocalizationsZh extends AppLocalizations {
return '第 $current 集 / 共 $total 集'; return '第 $current 集 / 共 $total 集';
} }
@override
String get clearScreen => '清屏';
@override @override
String minutesAgo(int count) { String minutesAgo(int count) {
return '$count 分钟前'; return '$count 分钟前';
+1
View File
@@ -46,6 +46,7 @@
"total": {"type": "int"} "total": {"type": "int"}
} }
}, },
"clearScreen": "清屏",
"minutesAgo": "{count} 分钟前", "minutesAgo": "{count} 分钟前",
"@minutesAgo": { "@minutesAgo": {
"placeholders": { "placeholders": {
+86 -122
View File
@@ -30,16 +30,6 @@ import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/playback_repository.dart';
/// The home feed has no overlay play button, so it keeps its own center hint.
/// Detail/series playback uses `_PlaybackOverlay.player-play-toggle` instead.
bool shouldShowFeedPauseIndicator({
required bool homeMode,
required bool selected,
required bool ready,
required bool playing,
required bool pip,
}) => homeMode && selected && ready && !playing && !pip;
class PlayerPage extends StatefulWidget { class PlayerPage extends StatefulWidget {
const PlayerPage({ const PlayerPage({
super.key, super.key,
@@ -80,6 +70,7 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver { class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic; late final PlayerLogic logic;
bool _feedChromeVisible = true;
@override @override
void initState() { void initState() {
@@ -198,9 +189,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
children: [ children: [
const ColoredBox(color: AppColors.ink), const ColoredBox(color: AppColors.ink),
?video, ?video,
if (!logic.chromeHidden) if (!logic.chromeHidden && (!widget.homeMode || _feedChromeVisible))
const IgnorePointer( IgnorePointer(
child: DecoratedBox( key: widget.homeMode ? const Key('home-feed-scrim') : null,
child: const DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
@@ -312,67 +304,59 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
), ),
), ),
), ),
if (shouldShowFeedPauseIndicator(
homeMode: widget.homeMode,
selected: selected,
ready: ready,
playing: logic.session.userWantsPlay,
pip: logic.pip,
))
Center(
child: PlayerCenterToggle(
key: const Key('home-feed-center-play'),
playing: false,
onTap: () => logic.session.setUserWantsPlay(true),
),
),
if (logic.pip || logic.pipPreparing) if (logic.pip || logic.pipPreparing)
const SizedBox.expand() const SizedBox.expand()
else if (widget.homeMode) else if (widget.homeMode)
HomeFeedOverlay( _feedChromeVisible
l10n: l10n, ? HomeFeedOverlay(
drama: drama, l10n: l10n,
episode: episode, drama: drama,
liked: interactions.isLiked(drama.id), episode: episode,
saved: context.watch<FavoriteController>().isSaved(drama.id), liked: interactions.isLiked(drama.id),
onLike: () async { saved: context.watch<FavoriteController>().isSaved(drama.id),
if (await ensureSignedIn(context)) { onLike: () async {
if (!context.mounted) return; if (await ensureSignedIn(context)) {
unawaited(context.read<Haptics>().lightImpact()); if (!context.mounted) return;
final result = await interactions.toggleDramaLike(drama.id); unawaited(context.read<Haptics>().lightImpact());
if (!result.isSuccess && context.mounted) { final result = await interactions.toggleDramaLike(
ScaffoldMessenger.of(context).showSnackBar( drama.id,
SnackBar( );
content: Text( if (!result.isSuccess && context.mounted) {
result.errorMessage ?? ScaffoldMessenger.of(context).showSnackBar(
AppLocalizations.of(context)!.likeFailed, SnackBar(
), content: Text(
), result.errorMessage ??
); AppLocalizations.of(context)!.likeFailed,
} ),
} ),
}, );
onSave: () async { }
final favorites = context.read<FavoriteController>(); }
if (await ensureSignedIn(context)) { },
if (!context.mounted) return; onSave: () async {
unawaited(context.read<Haptics>().lightImpact()); final favorites = context.read<FavoriteController>();
final ok = await favorites.toggle(drama.id, card: drama); if (await ensureSignedIn(context)) {
if (ok || !context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( unawaited(context.read<Haptics>().lightImpact());
SnackBar(content: Text(l10n.favoriteToggleFailed)), final ok = await favorites.toggle(drama.id, card: drama);
); if (ok || !context.mounted) return;
} ScaffoldMessenger.of(context).showSnackBar(
}, SnackBar(content: Text(l10n.favoriteToggleFailed)),
onComments: () => logic.showComments(context, drama), );
onShare: () => logic.shareDrama(context, drama), }
onEpisodes: () => logic.showEpisodes(context), },
onDownload: () => requestEpisodeDownload(context, episode.id), onComments: () => logic.showComments(context, drama),
onSearch: widget.onSearch ?? () => logic.showDiscover(context), onShare: () => logic.shareDrama(context, drama),
onPip: logic.pictureInPicture.isPlatformSupported onEpisodes: () => logic.showEpisodes(context),
? () => unawaited(logic.enterPip(context)) onDownload: () => requestEpisodeDownload(context, episode.id),
: null, onClear: () => setState(() => _feedChromeVisible = false),
) onSearch:
widget.onSearch ?? () => logic.showDiscover(context),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
)
: const SizedBox.shrink()
else else
_PlaybackOverlay( _PlaybackOverlay(
l10n: l10n, l10n: l10n,
@@ -433,6 +417,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
? (position) => logic.seekTo(context, videoPort!, position) ? (position) => logic.seekTo(context, videoPort!, position)
: null, : null,
), ),
if (widget.homeMode && !_feedChromeVisible)
Positioned.fill(
child: GestureDetector(
key: const Key('home-feed-video-tap'),
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _feedChromeVisible = true),
),
),
], ],
); );
} }
@@ -608,50 +600,17 @@ class _PlaybackOverlay extends StatefulWidget {
} }
class _PlaybackOverlayState extends State<_PlaybackOverlay> { class _PlaybackOverlayState extends State<_PlaybackOverlay> {
Timer? _hideTimer;
bool _controlsVisible = true; bool _controlsVisible = true;
@override
void initState() {
super.initState();
_scheduleHide();
}
@override
void dispose() {
_hideTimer?.cancel();
super.dispose();
}
void _scheduleHide() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(milliseconds: 2500), () {
if (mounted) setState(() => _controlsVisible = false);
});
}
void _reveal() {
setState(() => _controlsVisible = true);
_scheduleHide();
}
void _togglePlay() {
widget.onTogglePlay();
_reveal();
}
void _onVideoTap() { void _onVideoTap() {
if (!_controlsVisible) { if (!_controlsVisible) {
_reveal(); setState(() => _controlsVisible = true);
} else { } else if (!widget.playing) {
_togglePlay(); widget.onTogglePlay();
} }
} }
void _run(VoidCallback action) { void _clearScreen() => setState(() => _controlsVisible = false);
action();
_reveal();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -673,13 +632,14 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
Center( if (widget.playing)
child: PlayerCenterToggle( Center(
key: const Key('player-play-toggle'), child: PlayerCenterToggle(
playing: widget.playing, key: const Key('player-play-toggle'),
onTap: _togglePlay, playing: true,
onTap: widget.onTogglePlay,
),
), ),
),
const Positioned( const Positioned(
right: 10, right: 10,
bottom: 14, bottom: 14,
@@ -715,7 +675,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
key: const Key('player-episodes'), key: const Key('player-episodes'),
icon: Icons.view_list_rounded, icon: Icons.view_list_rounded,
label: widget.l10n.episodes, label: widget.l10n.episodes,
onTap: () => _run(widget.onEpisodes), onTap: widget.onEpisodes,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -724,7 +684,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
key: const Key('player-comments'), key: const Key('player-comments'),
icon: Icons.chat_bubble_outline_rounded, icon: Icons.chat_bubble_outline_rounded,
label: widget.l10n.comments, label: widget.l10n.comments,
onTap: () => _run(widget.onComments), onTap: widget.onComments,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -733,9 +693,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
height: 32, height: 32,
child: FilledButton( child: FilledButton(
key: const Key('player-next'), key: const Key('player-next'),
onPressed: widget.onNext == null onPressed: widget.onNext,
? null
: () => _run(widget.onNext!),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.blue, backgroundColor: AppColors.blue,
disabledBackgroundColor: AppColors.raised, disabledBackgroundColor: AppColors.raised,
@@ -775,7 +733,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
const SizedBox(height: 12), const SizedBox(height: 12),
PlayerFeaturedComment( PlayerFeaturedComment(
drama: widget.drama, drama: widget.drama,
onViewAll: () => _run(widget.onComments), onViewAll: widget.onComments,
), ),
], ],
), ),
@@ -800,7 +758,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
key: const Key('player-back'), key: const Key('player-back'),
tooltip: widget.l10n.backToSeries, tooltip: widget.l10n.backToSeries,
icon: Icons.chevron_left_rounded, icon: Icons.chevron_left_rounded,
onPressed: () => _run(widget.onBack!), onPressed: widget.onBack!,
) )
else else
const SizedBox(width: 36), const SizedBox(width: 36),
@@ -822,6 +780,13 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_TopAction(
key: const Key('player-clear-screen'),
tooltip: widget.l10n.clearScreen,
icon: Icons.fullscreen_rounded,
onPressed: _clearScreen,
),
const SizedBox(width: 8),
_TopAction( _TopAction(
key: const Key('player-more'), key: const Key('player-more'),
tooltip: widget.l10n.more, tooltip: widget.l10n.more,
@@ -907,7 +872,6 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
), ),
), ),
); );
if (mounted) _reveal();
} }
} }
@@ -26,6 +26,7 @@ class HomeFeedOverlay extends StatelessWidget {
required this.onShare, required this.onShare,
required this.onEpisodes, required this.onEpisodes,
required this.onDownload, required this.onDownload,
required this.onClear,
required this.onSearch, required this.onSearch,
this.onPip, this.onPip,
}); });
@@ -41,6 +42,7 @@ class HomeFeedOverlay extends StatelessWidget {
final VoidCallback onShare; final VoidCallback onShare;
final VoidCallback onEpisodes; final VoidCallback onEpisodes;
final VoidCallback onDownload; final VoidCallback onDownload;
final VoidCallback onClear;
final VoidCallback onSearch; final VoidCallback onSearch;
final VoidCallback? onPip; final VoidCallback? onPip;
@@ -96,6 +98,13 @@ class HomeFeedOverlay extends StatelessWidget {
onPressed: onSearch, onPressed: onSearch,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_RoundIcon(
key: const Key('home-feed-clear-screen'),
tooltip: l10n.clearScreen,
icon: Icons.fullscreen_rounded,
onPressed: onClear,
),
const SizedBox(width: 8),
_RoundIcon( _RoundIcon(
key: const Key('home-feed-more'), key: const Key('home-feed-more'),
tooltip: l10n.more, tooltip: l10n.more,
+1
View File
@@ -101,6 +101,7 @@ void main() {
onShare: () {}, onShare: () {},
onEpisodes: () {}, onEpisodes: () {},
onDownload: () {}, onDownload: () {},
onClear: () {},
onSearch: () {}, onSearch: () {},
), ),
), ),
+3 -5
View File
@@ -5,16 +5,14 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart';
void main() { void main() {
testWidgets('should_use_one_shared_52px_style_for_home_and_player', ( testWidgets('should_use_one_shared_52px_center_toggle_style', (tester) async {
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: Row( body: Row(
children: [ children: [
PlayerCenterToggle( PlayerCenterToggle(
key: const Key('home-feed-center-play'), key: const Key('test-center-toggle-a'),
playing: false, playing: false,
onTap: () {}, onTap: () {},
), ),
@@ -30,7 +28,7 @@ void main() {
); );
for (final key in const [ for (final key in const [
Key('home-feed-center-play'), Key('test-center-toggle-a'),
Key('player-play-toggle'), Key('player-play-toggle'),
]) { ]) {
expect(tester.getSize(find.byKey(key)), const Size(52, 52)); expect(tester.getSize(find.byKey(key)), const Size(52, 52));
+41 -39
View File
@@ -9,7 +9,6 @@ import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_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/repositories/watch_history_repository.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_center_toggle.dart';
import '../support/fake_catalog.dart'; import '../support/fake_catalog.dart';
@@ -70,7 +69,7 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('should_keep_single_pause_toggle_when_detail_player_is_paused', ( testWidgets('should_hide_pause_toggle_when_playback_is_paused', (
tester, tester,
) async { ) async {
await _pumpApp(tester); await _pumpApp(tester);
@@ -86,12 +85,16 @@ void main() {
await tester.tap(find.byKey(const Key('player-play-toggle'))); await tester.tap(find.byKey(const Key('player-play-toggle')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-video-tap')));
await tester.pump();
expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget);
}); });
testWidgets('should_hide_controls_after_2_5s_and_reveal_on_video_tap', ( testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', (
tester, tester,
) async { ) async {
await _pumpApp(tester); await _pumpApp(tester);
@@ -108,7 +111,14 @@ void main() {
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
expect(find.byType(PlayerCenterToggle), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2600)); await tester.pump(const Duration(seconds: 5));
await tester.pump();
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
expect(find.byType(PlayerCenterToggle), findsOneWidget);
await tester.tap(find.byKey(const Key('player-clear-screen')));
await tester.pump(); await tester.pump();
expect(find.byKey(const Key('player-controls-top')), findsNothing); expect(find.byKey(const Key('player-controls-top')), findsNothing);
@@ -124,39 +134,6 @@ void main() {
expect(find.byType(PlayerCenterToggle), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget);
}); });
test('should_only_show_feed_pause_indicator_in_home_mode', () {
expect(
shouldShowFeedPauseIndicator(
homeMode: true,
selected: true,
ready: true,
playing: false,
pip: false,
),
isTrue,
);
expect(
shouldShowFeedPauseIndicator(
homeMode: false,
selected: true,
ready: true,
playing: false,
pip: false,
),
isFalse,
);
expect(
shouldShowFeedPauseIndicator(
homeMode: true,
selected: true,
ready: true,
playing: true,
pip: false,
),
isFalse,
);
});
testWidgets('should_fit_player_header_on_320_width', (tester) async { testWidgets('should_fit_player_header_on_320_width', (tester) async {
await _pumpApp(tester, size: const Size(320, 568)); await _pumpApp(tester, size: const Size(320, 568));
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
@@ -174,6 +151,31 @@ void main() {
); );
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-clear-screen')));
await tester.pump();
expect(find.byKey(const Key('home-feed-title')), findsNothing);
expect(find.byKey(const Key('home-feed-info-card')), findsNothing);
expect(find.byKey(const Key('home-feed-scrim')), findsNothing);
expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-video-tap')));
await tester.pump();
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget);
});
} }
void _expectFullscreenOverlay(WidgetTester tester) { void _expectFullscreenOverlay(WidgetTester tester) {