From 801ab13938a66373d9b3d5568596f992697732d0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:09:38 +0700 Subject: [PATCH] fix(lyrics): keep focus fixed when player controls hide --- lib/screens/now_playing_screen.dart | 42 ++++++++++---------- test/now_playing_lyrics_transition_test.dart | 23 ++++++++--- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 3244eb72..ab01a66b 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -59,7 +59,6 @@ final _log = AppLogger('NowPlaying'); const kNowPlayingArtworkHeroTag = 'now-playing-artwork'; const _mornyeLyricFontSize = 34.0; -const _mornyeLyricFocusAlignment = 0.22; /// Slide-up route for the full player. Supports live drag-to-dismiss: the /// page follows the finger (via [startDrag]/[updateDrag]/[endDrag]) and @@ -2829,6 +2828,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { double? _viewportHeight; Offset? _layoutVisibility; + // The header stays fixed when controls collapse. Anchor lyrics to it, not + // to a fraction of the growing viewport, including during the transition. + double get _focusInset => + (MediaQuery.sizeOf(context).height * 0.06).clamp(16.0, 48.0); + @override void initState() { super.initState(); @@ -3037,18 +3041,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final extents = _lineExtents; if (context.isMornye && extents != null && index < extents.length) { final position = _scroll.position; - final padding = syncedLyricsCenterPadding( - viewportDimension: position.viewportDimension, - estimatedLineExtent: _estimatedLyricExtent, - ); - final target = - extents.take(index).fold(0.0, (sum, extent) => sum + extent) + - padding - - (position.viewportDimension - extents[index]).clamp( - 0.0, - double.infinity, - ) * - _mornyeLyricFocusAlignment; + final target = extents + .take(index) + .fold(0.0, (sum, extent) => sum + extent); final offset = target.clamp( position.minScrollExtent, position.maxScrollExtent, @@ -3175,17 +3170,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } }); } - final centerPadding = syncedLyricsCenterPadding( - viewportDimension: constraints.maxHeight, - estimatedLineExtent: _estimatedLyricExtent, - ); + final topPadding = mornye + ? _focusInset + : syncedLyricsCenterPadding( + viewportDimension: constraints.maxHeight, + estimatedLineExtent: _estimatedLyricExtent, + ); // Leave enough trailing space for the final line to reach the same // upper focus position as every other line. final bottomPadding = mornye && _lineExtents!.isNotEmpty - ? ((constraints.maxHeight - _lineExtents!.last) * - (1 - _mornyeLyricFocusAlignment)) - .clamp(centerPadding, double.infinity) - : centerPadding; + ? (constraints.maxHeight - _lineExtents!.last - topPadding).clamp( + topPadding, + double.infinity, + ) + : topPadding; return ListView.builder( controller: _scroll, itemExtentBuilder: mornye @@ -3196,7 +3194,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { constraints.maxWidth - 48, ) : null, - padding: EdgeInsets.fromLTRB(24, centerPadding, 24, bottomPadding), + padding: EdgeInsets.fromLTRB(24, topPadding, 24, bottomPadding), itemCount: lines.length + (widget.credits == null ? 0 : 1), itemBuilder: (context, index) { if (index == lines.length) return widget.credits!; diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index b06192b2..61113464 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -357,11 +357,7 @@ void main() { void expectUpperFocus(Finder line) { final bounds = tester.getRect(line); final viewport = tester.getRect(list); - final space = (viewport.height - bounds.height - 32).clamp( - 0.0, - double.infinity, - ); - expect(bounds.top, closeTo(viewport.top + 16 + space * 0.22, 2)); + expect(bounds.top, closeTo(viewport.top + 16 + 780 * 0.06, 2)); } expectUpperFocus(current); @@ -448,6 +444,8 @@ void main() { testWidgets( 'lyrics hide controls after five idle seconds and restore on tap (reduced motion: $reducedMotion)', (tester) async { + const lyric = 'First lyric with several words on this line'; + metadataOverrides['lyrics'] = '[00:00.00]$lyric\n[01:00.00]Second line'; await pumpNowPlaying( tester, theme: MornyeTheme.build(Brightness.dark), @@ -477,7 +475,15 @@ void main() { expect(play.hitTestable(), findsOneWidget); final list = find.byType(ListView); final originalHeight = tester.getSize(list).height; + final originalTop = tester.getTopLeft(find.text(lyric)).dy; await tester.pump(const Duration(milliseconds: 100)); + for (var frame = 0; frame < 10; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.text(lyric)).dy, + closeTo(originalTop, 1), + ); + } await tester.pumpAndSettle(); expect(play.hitTestable(), findsNothing); expect(volume.hitTestable(), findsNothing); @@ -492,6 +498,13 @@ void main() { Future reveal() async { final bounds = tester.getRect(list); await tester.tapAt(Offset(bounds.right - 6, bounds.top + 10)); + for (var frame = 0; frame < 10; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.text(lyric)).dy, + closeTo(originalTop, 1), + ); + } await tester.pumpAndSettle(); expect(play.hitTestable(), findsOneWidget); expect(lyricsButton.hitTestable(), findsOneWidget);