diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0641b60..5b757ed 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -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 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 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(); } } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index faca527..c6653ba 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -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(), ), ), diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart new file mode 100644 index 0000000..b15e355 --- /dev/null +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart new file mode 100644 index 0000000..1e3a0ae --- /dev/null +++ b/test/player/player_center_toggle_test.dart @@ -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( + 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( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ); + expect(icon.size, 26); + } + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 22acdaa..2c9607b 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -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(