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"}
}
},
"clearScreen": "Clear screen",
"minutesAgo": "{count} min ago",
"@minutesAgo": {
"placeholders": {
+1
View File
@@ -46,6 +46,7 @@
"total": {"type": "int"}
}
},
"clearScreen": "Bersihkan layar",
"minutesAgo": "{count} mnt lalu",
"@minutesAgo": {
"placeholders": {
+6
View File
@@ -340,6 +340,12 @@ abstract class AppLocalizations {
/// **'Episode {current} / {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.
///
/// In en, this message translates to:
+3
View File
@@ -133,6 +133,9 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Episode $current / $total';
}
@override
String get clearScreen => 'Clear screen';
@override
String minutesAgo(int count) {
return '$count min ago';
+3
View File
@@ -133,6 +133,9 @@ class AppLocalizationsId extends AppLocalizations {
return 'Episode $current / $total';
}
@override
String get clearScreen => 'Bersihkan layar';
@override
String minutesAgo(int count) {
return '$count mnt lalu';
+3
View File
@@ -130,6 +130,9 @@ class AppLocalizationsZh extends AppLocalizations {
return '第 $current 集 / 共 $total 集';
}
@override
String get clearScreen => '清屏';
@override
String minutesAgo(int count) {
return '$count 分钟前';
+1
View File
@@ -46,6 +46,7 @@
"total": {"type": "int"}
}
},
"clearScreen": "清屏",
"minutesAgo": "{count} 分钟前",
"@minutesAgo": {
"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/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 {
const PlayerPage({
super.key,
@@ -80,6 +70,7 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
bool _feedChromeVisible = true;
@override
void initState() {
@@ -198,9 +189,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
children: [
const ColoredBox(color: AppColors.ink),
?video,
if (!logic.chromeHidden)
const IgnorePointer(
child: DecoratedBox(
if (!logic.chromeHidden && (!widget.homeMode || _feedChromeVisible))
IgnorePointer(
key: widget.homeMode ? const Key('home-feed-scrim') : null,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
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)
const SizedBox.expand()
else if (widget.homeMode)
HomeFeedOverlay(
l10n: l10n,
drama: drama,
episode: episode,
liked: interactions.isLiked(drama.id),
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
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),
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
)
_feedChromeVisible
? HomeFeedOverlay(
l10n: l10n,
drama: drama,
episode: episode,
liked: interactions.isLiked(drama.id),
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(
drama.id,
);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
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),
onClear: () => setState(() => _feedChromeVisible = false),
onSearch:
widget.onSearch ?? () => logic.showDiscover(context),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
)
: const SizedBox.shrink()
else
_PlaybackOverlay(
l10n: l10n,
@@ -433,6 +417,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
? (position) => logic.seekTo(context, videoPort!, position)
: 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> {
Timer? _hideTimer;
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() {
if (!_controlsVisible) {
_reveal();
} else {
_togglePlay();
setState(() => _controlsVisible = true);
} else if (!widget.playing) {
widget.onTogglePlay();
}
}
void _run(VoidCallback action) {
action();
_reveal();
}
void _clearScreen() => setState(() => _controlsVisible = false);
@override
Widget build(BuildContext context) {
@@ -673,13 +632,14 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
Expanded(
child: Stack(
children: [
Center(
child: PlayerCenterToggle(
key: const Key('player-play-toggle'),
playing: widget.playing,
onTap: _togglePlay,
if (widget.playing)
Center(
child: PlayerCenterToggle(
key: const Key('player-play-toggle'),
playing: true,
onTap: widget.onTogglePlay,
),
),
),
const Positioned(
right: 10,
bottom: 14,
@@ -715,7 +675,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
key: const Key('player-episodes'),
icon: Icons.view_list_rounded,
label: widget.l10n.episodes,
onTap: () => _run(widget.onEpisodes),
onTap: widget.onEpisodes,
),
),
const SizedBox(width: 8),
@@ -724,7 +684,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
key: const Key('player-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: widget.l10n.comments,
onTap: () => _run(widget.onComments),
onTap: widget.onComments,
),
),
const SizedBox(width: 8),
@@ -733,9 +693,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
height: 32,
child: FilledButton(
key: const Key('player-next'),
onPressed: widget.onNext == null
? null
: () => _run(widget.onNext!),
onPressed: widget.onNext,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
disabledBackgroundColor: AppColors.raised,
@@ -775,7 +733,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
const SizedBox(height: 12),
PlayerFeaturedComment(
drama: widget.drama,
onViewAll: () => _run(widget.onComments),
onViewAll: widget.onComments,
),
],
),
@@ -800,7 +758,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
key: const Key('player-back'),
tooltip: widget.l10n.backToSeries,
icon: Icons.chevron_left_rounded,
onPressed: () => _run(widget.onBack!),
onPressed: widget.onBack!,
)
else
const SizedBox(width: 36),
@@ -822,6 +780,13 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
),
),
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(
key: const Key('player-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.onEpisodes,
required this.onDownload,
required this.onClear,
required this.onSearch,
this.onPip,
});
@@ -41,6 +42,7 @@ class HomeFeedOverlay extends StatelessWidget {
final VoidCallback onShare;
final VoidCallback onEpisodes;
final VoidCallback onDownload;
final VoidCallback onClear;
final VoidCallback onSearch;
final VoidCallback? onPip;
@@ -96,6 +98,13 @@ class HomeFeedOverlay extends StatelessWidget {
onPressed: onSearch,
),
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(
key: const Key('home-feed-more'),
tooltip: l10n.more,
+1
View File
@@ -101,6 +101,7 @@ void main() {
onShare: () {},
onEpisodes: () {},
onDownload: () {},
onClear: () {},
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';
void main() {
testWidgets('should_use_one_shared_52px_style_for_home_and_player', (
tester,
) async {
testWidgets('should_use_one_shared_52px_center_toggle_style', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Row(
children: [
PlayerCenterToggle(
key: const Key('home-feed-center-play'),
key: const Key('test-center-toggle-a'),
playing: false,
onTap: () {},
),
@@ -30,7 +28,7 @@ void main() {
);
for (final key in const [
Key('home-feed-center-play'),
Key('test-center-toggle-a'),
Key('player-play-toggle'),
]) {
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/playback_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 '../support/fake_catalog.dart';
@@ -70,7 +69,7 @@ void main() {
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,
) async {
await _pumpApp(tester);
@@ -86,12 +85,16 @@ void main() {
await tester.tap(find.byKey(const Key('player-play-toggle')));
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('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,
) async {
await _pumpApp(tester);
@@ -108,7 +111,14 @@ void main() {
expect(find.byKey(const Key('player-controls-bottom')), 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();
expect(find.byKey(const Key('player-controls-top')), findsNothing);
@@ -124,39 +134,6 @@ void main() {
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 {
await _pumpApp(tester, size: const Size(320, 568));
await tester.binding.handlePopRoute();
@@ -174,6 +151,31 @@ void main() {
);
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) {