fix(player): 统一暂停按钮并自动隐藏控制层
This commit is contained in:
1 parent
568f051b7a
commit
03e34b3b57
5 files changed
+353
-171
No files matched your search
+228
-170
@@ -24,6 +24,7 @@ import '../../l10n/app_localizations.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
import 'widgets/home_feed_overlay.dart';
|
||||
import 'widgets/player_center_toggle.dart';
|
||||
import 'widgets/player_featured_comment.dart';
|
||||
import 'widgets/seek_progress_bar.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
@@ -318,23 +319,11 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
playing: logic.session.userWantsPlay,
|
||||
pip: logic.pip,
|
||||
))
|
||||
const Center(
|
||||
key: Key('home-feed-center-play'),
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 48,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: PlayerCenterToggle(
|
||||
key: const Key('home-feed-center-play'),
|
||||
playing: false,
|
||||
onTap: () => logic.session.setUserWantsPlay(true),
|
||||
),
|
||||
),
|
||||
if (logic.pip || logic.pipPreparing)
|
||||
@@ -571,7 +560,7 @@ class _UnlockCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaybackOverlay extends StatelessWidget {
|
||||
class _PlaybackOverlay extends StatefulWidget {
|
||||
const _PlaybackOverlay({
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
@@ -614,123 +603,185 @@ class _PlaybackOverlay extends StatelessWidget {
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
|
||||
@override
|
||||
State<_PlaybackOverlay> createState() => _PlaybackOverlayState();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
void _run(VoidCallback action) {
|
||||
action();
|
||||
_reveal();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
_topBar(context),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
key: const Key('player-play-toggle'),
|
||||
onTap: onTogglePlay,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
playing
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
right: 10,
|
||||
bottom: 14,
|
||||
child: Text(
|
||||
'1.0x',
|
||||
style: TextStyle(color: Color(0xFFDDDDDD), fontSize: 9),
|
||||
),
|
||||
),
|
||||
],
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const Key('player-video-tap'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _onVideoTap,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: Column(
|
||||
if (_controlsVisible)
|
||||
Column(
|
||||
key: const Key('player-controls-visible'),
|
||||
children: [
|
||||
SeekProgressBar(
|
||||
valueListenable: controller,
|
||||
semanticsLabel: l10n.seekVideo,
|
||||
showTimes: false,
|
||||
onScrubStart: onScrubStart,
|
||||
onSeek: onSeek,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-episodes'),
|
||||
icon: Icons.view_list_rounded,
|
||||
label: l10n.episodes,
|
||||
onTap: onEpisodes,
|
||||
_topBar(context),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: PlayerCenterToggle(
|
||||
key: const Key('player-play-toggle'),
|
||||
playing: widget.playing,
|
||||
onTap: _togglePlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: FilledButton(
|
||||
key: const Key('player-next'),
|
||||
onPressed: onNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
disabledBackgroundColor: AppColors.raised,
|
||||
disabledForegroundColor: AppColors.subtle,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.nextEpisode,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.skip_next_rounded, size: 14),
|
||||
],
|
||||
const Positioned(
|
||||
right: 10,
|
||||
bottom: 14,
|
||||
child: Text(
|
||||
'1.0x',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFDDDDDD),
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
key: const Key('player-controls-bottom'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 14, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
SeekProgressBar(
|
||||
key: const Key('player-progress'),
|
||||
valueListenable: widget.controller,
|
||||
semanticsLabel: widget.l10n.seekVideo,
|
||||
showTimes: false,
|
||||
onScrubStart: widget.onScrubStart,
|
||||
onSeek: widget.onSeek,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-episodes'),
|
||||
icon: Icons.view_list_rounded,
|
||||
label: widget.l10n.episodes,
|
||||
onTap: () => _run(widget.onEpisodes),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: widget.l10n.comments,
|
||||
onTap: () => _run(widget.onComments),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: FilledButton(
|
||||
key: const Key('player-next'),
|
||||
onPressed: widget.onNext == null
|
||||
? null
|
||||
: () => _run(widget.onNext!),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
disabledBackgroundColor: AppColors.raised,
|
||||
disabledForegroundColor: AppColors.subtle,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.l10n.nextEpisode,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.skip_next_rounded,
|
||||
size: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PlayerFeaturedComment(
|
||||
drama: widget.drama,
|
||||
onViewAll: () => _run(widget.onComments),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
PlayerFeaturedComment(drama: drama, onViewAll: onComments),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -738,43 +789,47 @@ class _PlaybackOverlay extends StatelessWidget {
|
||||
|
||||
Widget _topBar(BuildContext context) {
|
||||
return SizedBox(
|
||||
key: const Key('player-controls-top'),
|
||||
height: 46,
|
||||
child: Row(
|
||||
children: [
|
||||
if (onBack != null)
|
||||
_TopAction(
|
||||
key: const Key('player-back'),
|
||||
tooltip: l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 28),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${localizedDramaTitle(l10n, drama)} · '
|
||||
'${l10n.episodeLabel(episode.number)}',
|
||||
key: const Key('player-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onBack != null)
|
||||
_TopAction(
|
||||
key: const Key('player-back'),
|
||||
tooltip: widget.l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: () => _run(widget.onBack!),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 28),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${localizedDramaTitle(widget.l10n, widget.drama)} · '
|
||||
'${widget.l10n.episodeLabel(widget.episode.number)}',
|
||||
key: const Key('player-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_TopAction(
|
||||
key: const Key('player-more'),
|
||||
tooltip: l10n.more,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
_TopAction(
|
||||
key: const Key('player-more'),
|
||||
tooltip: widget.l10n.more,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -794,62 +849,65 @@ class _PlaybackOverlay extends StatelessWidget {
|
||||
Icons.download_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.downloads),
|
||||
title: Text(widget.l10n.downloads),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onDownload();
|
||||
widget.onDownload();
|
||||
},
|
||||
),
|
||||
if (onPip != null)
|
||||
if (widget.onPip != null)
|
||||
ListTile(
|
||||
key: const Key('player-more-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.pictureInPicture),
|
||||
title: Text(widget.l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onPip!();
|
||||
widget.onPip!();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-like'),
|
||||
leading: Icon(
|
||||
liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
color: liked ? AppColors.blue : AppColors.muted,
|
||||
widget.liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
color: widget.liked ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
title: Text(l10n.like),
|
||||
title: Text(widget.l10n.like),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onLike();
|
||||
widget.onLike();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-favorite'),
|
||||
leading: Icon(
|
||||
saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
color: saved ? AppColors.blue : AppColors.muted,
|
||||
widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
color: widget.saved ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
title: Text(l10n.favorite),
|
||||
title: Text(widget.l10n.favorite),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onSave();
|
||||
widget.onSave();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-share'),
|
||||
leading: const Icon(Icons.share_outlined, color: AppColors.muted),
|
||||
title: Text(l10n.share),
|
||||
title: Text(widget.l10n.share),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onShare();
|
||||
widget.onShare();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) _reveal();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class HomeFeedOverlay extends StatelessWidget {
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 10, 12),
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 12, 16),
|
||||
child: _rail(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Shared play/pause control used by the home feed and full-screen player.
|
||||
class PlayerCenterToggle extends StatelessWidget {
|
||||
const PlayerCenterToggle({
|
||||
super.key,
|
||||
required this.playing,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool playing;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: playing ? 'Pause' : 'Play',
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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 {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
PlayerCenterToggle(
|
||||
key: const Key('home-feed-center-play'),
|
||||
playing: false,
|
||||
onTap: () {},
|
||||
),
|
||||
PlayerCenterToggle(
|
||||
key: const Key('player-play-toggle'),
|
||||
playing: true,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (final key in const [
|
||||
Key('home-feed-center-play'),
|
||||
Key('player-play-toggle'),
|
||||
]) {
|
||||
expect(tester.getSize(find.byKey(key)), const Size(52, 52));
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Container)),
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
expect(decoration.color, const Color(0x80000000));
|
||||
expect(decoration.shape, BoxShape.circle);
|
||||
final icon = tester.widget<Icon>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||
);
|
||||
expect(icon.size, 26);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
import '../support/fake_category.dart';
|
||||
@@ -90,6 +91,39 @@ void main() {
|
||||
expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_hide_controls_after_2_5s_and_reveal_on_video_tap', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(tester);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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.pump(const Duration(milliseconds: 2600));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('player-controls-top')), findsNothing);
|
||||
expect(find.byKey(const Key('player-controls-bottom')), findsNothing);
|
||||
expect(find.byType(PlayerCenterToggle), findsNothing);
|
||||
expect(find.byKey(const Key('player-video-tap')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('player-video-tap')));
|
||||
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);
|
||||
});
|
||||
|
||||
test('should_only_show_feed_pause_indicator_in_home_mode', () {
|
||||
expect(
|
||||
shouldShowFeedPauseIndicator(
|
||||
|
||||
Reference in new issue
Block a user