From e09d0c1609d7d22cea59911b33f260e7e47607d2 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:26:25 +0700 Subject: [PATCH] fix(lyrics): hide controls after five seconds only during playback --- lib/screens/now_playing_screen.dart | 16 +++- lib/widgets/mornye_landscape_player.dart | 13 ++- test/now_playing_lyrics_transition_test.dart | 85 +++++++++++++++++--- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 58c77228..0ea9055b 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -316,6 +316,7 @@ class NowPlayingScreen extends ConsumerStatefulWidget { class _NowPlayingScreenState extends ConsumerState { final PageController _pageController = PageController(); ProviderSubscription>? _mediaItemSub; + ProviderSubscription? _lyricsPlayingSub; String? _loadedSource; String? _loadedResolvedSource; String? _loadedMetadataPath; @@ -359,6 +360,15 @@ class _NowPlayingScreenState extends ConsumerState { inspectUnresolvedContentUri: _currentPage == 1, ), ); + _lyricsPlayingSub = ref.listenManual(playbackPlayingProvider, ( + previous, + playing, + ) { + if (!playing && _lyricsControlsHidden) { + setState(() => _lyricsControlsHidden = false); + } + _scheduleLyricsControlsHide(); + }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _loadMetadataForItem(ref.read(currentMediaItemProvider).value); @@ -380,6 +390,7 @@ class _NowPlayingScreenState extends ConsumerState { void dispose() { _lyricsIdleTimer?.cancel(); _mediaItemSub?.close(); + _lyricsPlayingSub?.close(); _pageController.dispose(); _artworkColorsChanged.dispose(); super.dispose(); @@ -1039,6 +1050,7 @@ class _NowPlayingScreenState extends ConsumerState { context.isMornye && !_landscape && _currentPage == 1 && + ref.read(playbackPlayingProvider) && !MediaQuery.accessibleNavigationOf(context); void _scheduleLyricsControlsHide() { @@ -1050,7 +1062,7 @@ class _NowPlayingScreenState extends ConsumerState { _lyricsPointers.isNotEmpty) { return; } - _lyricsIdleTimer = Timer(const Duration(seconds: 3), () { + _lyricsIdleTimer = Timer(const Duration(seconds: 5), () { if (!mounted || !_canAutoHideLyricsControls) return; if (ModalRoute.of(context)?.isCurrent == false) { _scheduleLyricsControlsHide(); @@ -1394,6 +1406,8 @@ class _NowPlayingScreenState extends ConsumerState { if (landscape) { return MornyeLandscapePlayer( page: _currentPage, + isPlaying: ref.watch(playbackPlayingProvider), + controlsHeldOpen: _lyricsOptionsOpen, onPageChanged: _setMornyePage, artwork: stage(artworkOnly: true), header: _trackHeader(mediaItem, colorScheme, compact: true), diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart index 306b75cc..aebee887 100644 --- a/lib/widgets/mornye_landscape_player.dart +++ b/lib/widgets/mornye_landscape_player.dart @@ -17,8 +17,10 @@ class MornyeLandscapePlayer extends StatefulWidget { required this.controls, required this.volume, required this.page, + required this.isPlaying, required this.onPageChanged, this.lyricsOptions, + this.controlsHeldOpen = false, }); final Widget artwork; @@ -28,6 +30,8 @@ class MornyeLandscapePlayer extends StatefulWidget { final Widget controls; final Widget volume; final int page; + final bool isPlaying; + final bool controlsHeldOpen; final ValueChanged onPageChanged; final Widget? lyricsOptions; @@ -54,21 +58,26 @@ class _MornyeLandscapePlayerState extends State { @override void didUpdateWidget(MornyeLandscapePlayer oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.page != widget.page) { + if (oldWidget.page != widget.page || + oldWidget.isPlaying != widget.isPlaying) { _actionsVisible = true; _scheduleHide(); + } else if (oldWidget.controlsHeldOpen != widget.controlsHeldOpen) { + _scheduleHide(); } } void _scheduleHide() { _hideTimer?.cancel(); if (widget.page != 1 || + !widget.isPlaying || + widget.controlsHeldOpen || !_actionsVisible || _audioOutputOpen || MediaQuery.accessibleNavigationOf(context)) { return; } - _hideTimer = Timer(const Duration(seconds: 3), () { + _hideTimer = Timer(const Duration(seconds: 5), () { if (!mounted) return; if (ModalRoute.of(context)?.isCurrent == false) { _scheduleHide(); diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 6f292f76..94f2a976 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -446,12 +446,13 @@ void main() { for (final reducedMotion in [false, true]) { testWidgets( - 'lyrics hide controls after three idle seconds and restore on tap (reduced motion: $reducedMotion)', + 'lyrics hide controls after five idle seconds and restore on tap (reduced motion: $reducedMotion)', (tester) async { await pumpNowPlaying( tester, theme: MornyeTheme.build(Brightness.dark), size: const Size(393, 852), + playback: PlaybackState(playing: true), wrapPlayer: (player) => Builder( builder: (context) => MediaQuery( data: MediaQuery.of( @@ -467,12 +468,12 @@ void main() { final queueButton = find.byIcon(CupertinoIcons.list_bullet); final play = find.widgetWithIcon( MornyePlaybackButton, - CupertinoIcons.play_fill, + CupertinoIcons.pause_fill, ); final volume = find.byType(MornyeVolumeControl); await tester.tap(lyricsButton); await tester.pump(); - await tester.pump(const Duration(milliseconds: 2900)); + await tester.pump(const Duration(milliseconds: 4900)); expect(play.hitTestable(), findsOneWidget); final list = find.byType(ListView); final originalHeight = tester.getSize(list).height; @@ -505,7 +506,7 @@ void main() { await tester.pump(const Duration(seconds: 5)); expect(volume.hitTestable(), findsOneWidget); await touch.up(); - await tester.pump(const Duration(milliseconds: 2900)); + await tester.pump(const Duration(milliseconds: 4900)); expect(volume.hitTestable(), findsOneWidget); await tester.pump(const Duration(milliseconds: 100)); await tester.pumpAndSettle(); @@ -514,18 +515,77 @@ void main() { await reveal(); await tester.tap(queueButton.hitTestable()); await tester.pumpAndSettle(); - await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(seconds: 6)); expect(play.hitTestable(), findsOneWidget); expect(queueButton.hitTestable(), findsOneWidget); await tester.tap(queueButton.hitTestable()); await tester.pumpAndSettle(); - await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(seconds: 6)); expect(play.hitTestable(), findsOneWidget); expect(tester.takeException(), isNull); }, ); } + for (final landscape in [false, true]) { + testWidgets( + 'paused lyrics keep controls visible and resume gets five seconds (landscape: $landscape)', + (tester) async { + final playback = StreamController(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: landscape ? const Size(852, 393) : const Size(393, 852), + playbackEvents: playback.stream, + ); + playback.add(PlaybackState(playing: false)); + mediaItems.add(item('many')); + await tester.pumpAndSettle(); + final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); + await tester.tap(lyricsButton); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 6)); + expect(lyricsButton.hitTestable(), findsOneWidget); + + playback.add(PlaybackState(playing: true)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 4900)); + expect(lyricsButton.hitTestable(), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(lyricsButton.hitTestable(), findsNothing); + + playback.add(PlaybackState(playing: false)); + await tester.pumpAndSettle(); + expect(lyricsButton.hitTestable(), findsOneWidget); + await tester.pump(const Duration(seconds: 6)); + expect(lyricsButton.hitTestable(), findsOneWidget); + + // Pausing during the countdown must cancel the pending hide too. + playback.add(PlaybackState(playing: true)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + playback.add(PlaybackState(playing: false)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 6)); + expect(lyricsButton.hitTestable(), findsOneWidget); + + playback.add(PlaybackState(playing: true)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 4900)); + expect(lyricsButton.hitTestable(), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(lyricsButton.hitTestable(), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets( 'accessible navigation keeps lyric controls available while idle', (tester) async { @@ -533,6 +593,7 @@ void main() { tester, theme: MornyeTheme.build(Brightness.dark), size: const Size(393, 852), + playback: PlaybackState(playing: true), wrapPlayer: (player) => Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith(accessibleNavigation: true), @@ -544,7 +605,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); await tester.pumpAndSettle(); - await tester.pump(const Duration(seconds: 5)); + await tester.pump(const Duration(seconds: 6)); expect(find.byType(MornyeVolumeControl).hitTestable(), findsOneWidget); expect( find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), @@ -1185,6 +1246,7 @@ void main() { tester, theme: MornyeTheme.build(Brightness.dark), size: const Size(393, 852), + playback: PlaybackState(playing: true), ); mediaItems.add(item('first')); await tester.pumpAndSettle(); @@ -1217,7 +1279,7 @@ void main() { expect(tester.takeException(), isNull); expect( - find.byIcon(CupertinoIcons.play_fill).hitTestable(), + find.byIcon(CupertinoIcons.pause_fill).hitTestable(), findsOneWidget, ); final volumeSlider = find.descendant( @@ -1243,7 +1305,7 @@ void main() { tester.getRect(lyric).top, greaterThan(tester.getRect(header).bottom), ); - await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); expect( find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), @@ -1838,6 +1900,7 @@ void main() { tester, theme: MornyeTheme.build(Brightness.dark), size: landscape ? const Size(844, 390) : const Size(390, 844), + playback: PlaybackState(playing: true), wrapPlayer: (player) => Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith(disableAnimations: true), @@ -1852,7 +1915,7 @@ void main() { final options = find.byKey(const ValueKey('lyrics-language-options')); await tester.tap(options); await tester.pumpAndSettle(); - await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(seconds: 6)); expect(find.text('Hide Pronunciation').hitTestable(), findsOneWidget); await tester.tap(find.text('Hide Pronunciation')); await tester.pump(); @@ -1860,7 +1923,7 @@ void main() { expect(find.text('Pronunciation'), findsNothing); expect(options.hitTestable(), findsOneWidget); - await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); expect(options.hitTestable(), findsNothing); if (landscape) {