From 0b35b134f0e981714ff59dd17584f1084d9cb146 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:18:10 +0700 Subject: [PATCH] fix(player): align lyrics and compact header with timeline --- lib/screens/now_playing_screen.dart | 115 +++++++++++++------ lib/widgets/mornye_player_slider.dart | 4 +- test/now_playing_lyrics_transition_test.dart | 98 +++++++++++++++- 3 files changed, 176 insertions(+), 41 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 154531f3..3307205b 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -51,6 +51,7 @@ import 'package:spotiflac_android/widgets/mornye_player_background.dart'; import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart'; import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; +import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; import 'package:spotiflac_android/widgets/mornye_landscape_player.dart'; @@ -66,6 +67,11 @@ final _log = AppLogger('NowPlaying'); const kNowPlayingArtworkHeroTag = 'now-playing-artwork'; const _mornyeLyricFontSize = 34.0; +const _mornyeTimelinePadding = 28.0; +const _mornyeContentInset = + _mornyeTimelinePadding + MornyePlayerSlider.horizontalInset; +const _mornyeHeaderButtonSize = 48.0; +const _mornyeCompactIconSize = 28.0; /// Slide-up route for the full player. Supports live drag-to-dismiss: the /// page follows the finger (via [startDrag]/[updateDrag]/[endDrag]) and @@ -1329,7 +1335,7 @@ class _NowPlayingScreenState extends ConsumerState { fullBleed ? motionHeight : artHeight * scale, ), const Rect.fromLTWH( - 28, + _mornyeContentInset, 8, compactCoverSize, compactCoverSize, @@ -1497,7 +1503,7 @@ class _NowPlayingScreenState extends ConsumerState { delegate: _PlayerHeaderLayout( progress: progress, compactHeight: compactHeaderHeight, - compactLeft: 28 + compactCoverSize + 12, + compactLeft: _mornyeContentInset + compactCoverSize + 12, ), children: [ LayoutId( @@ -1577,8 +1583,8 @@ class _NowPlayingScreenState extends ConsumerState { }) { final progress = compactProgress ?? (compact ? 1.0 : 0.0); final actionSize = compactProgress == null - ? (_currentPage == 0 ? 24.0 : 28.0) - : 24 + 4 * progress; + ? (_currentPage == 0 ? 24.0 : _mornyeCompactIconSize) + : 24 + (_mornyeCompactIconSize - 24) * progress; return Builder( builder: (context) => Row( children: [ @@ -1640,17 +1646,20 @@ class _NowPlayingScreenState extends ConsumerState { color: colorScheme.onSurface, ), Builder( - builder: (buttonContext) => IconButton( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - color: colorScheme.onSurface, - iconSize: actionSize, - icon: const Icon(CupertinoIcons.ellipsis), - onPressed: () => _showMoreActions( - context: context, - mediaItem: mediaItem, - source: mediaItem.extras?['source']?.toString() ?? '', - colorScheme: colorScheme, - anchor: mornyeMenuAnchor(buttonContext), + builder: (buttonContext) => SizedBox.square( + dimension: _mornyeHeaderButtonSize, + child: IconButton( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + color: colorScheme.onSurface, + iconSize: actionSize, + icon: const Icon(CupertinoIcons.ellipsis), + onPressed: () => _showMoreActions( + context: context, + mediaItem: mediaItem, + source: mediaItem.extras?['source']?.toString() ?? '', + colorScheme: colorScheme, + anchor: mornyeMenuAnchor(buttonContext), + ), ), ), ), @@ -1791,7 +1800,7 @@ class _NowPlayingScreenState extends ConsumerState { ), if (options != null) Positioned( - left: 24, + left: context.isMornye ? _mornyeContentInset : 24, bottom: 8, child: _autoHidingLyricsControls(options), ), @@ -2581,11 +2590,17 @@ class _PlayerHeaderLayout extends MultiChildLayoutDelegate { @override void performLayout(Size size) { final left = 28 + (compactLeft - 28) * progress; + // Align the visible ellipsis with the timeline while leaving its touch + // target wider than the glyph and interpolating the same live header. + const compactRight = + _mornyeContentInset - + (_mornyeHeaderButtonSize - _mornyeCompactIconSize) / 2; + final right = 28 + (compactRight - 28) * progress; final header = layoutChild( _PlayerHeaderSlot.header, BoxConstraints( - minWidth: size.width - left - 28, - maxWidth: size.width - left - 28, + minWidth: size.width - left - right, + maxWidth: size.width - left - right, minHeight: compactHeight * progress, ), ); @@ -2664,7 +2679,9 @@ class _PlaybackControls extends ConsumerWidget { final position = ref.watch(playbackPositionProvider); final elapsedSeconds = (preview ?? position).inSeconds; return Padding( - padding: EdgeInsets.symmetric(horizontal: mornye ? 28 : 16), + padding: EdgeInsets.symmetric( + horizontal: mornye ? _mornyeTimelinePadding : 16, + ), child: Column( children: [ SliderTheme( @@ -2698,7 +2715,9 @@ class _PlaybackControls extends ConsumerWidget { ), Padding( padding: EdgeInsets.symmetric( - horizontal: mornye ? 8 : 12, + horizontal: mornye + ? MornyePlayerSlider.horizontalInset + : 12, ), child: Row( children: [ @@ -3342,20 +3361,39 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { }, child: LayoutBuilder( builder: (context, constraints) { + final inset = mornye ? _mornyeContentInset : 24.0; + final contentWidth = (constraints.maxWidth - inset * 2).clamp( + 0.0, + double.infinity, + ); + // Lay out at the existing lyric width, then fit the whole line to + // the timeline. Scaling text, phrase gaps and supplements together + // preserves wrapping instead of pushing extra words onto a new row. + final layoutWidth = (constraints.maxWidth - 48).clamp( + 0.0, + double.infinity, + ); + final lyricScale = mornye && layoutWidth > 0 + ? contentWidth / layoutWidth + : 1.0; if (mornye) { final previousExtents = _lineExtents; - _measureMornyeLines( - (constraints.maxWidth - 48).clamp(0, double.infinity), - ); + _measureMornyeLines(layoutWidth); // Reuse text measurements; only interpolate row heights as the // supplements fade. Scrolling follows the same animation clock. - _lineExtents = [ - for (final (primary, pronunciation, translation) - in _lineMeasurements) - primary + - pronunciation * visibility.dx + - translation * visibility.dy, - ]; + _lineExtents = List.generate(_lineMeasurements.length, (row) { + final (primary, pronunciation, translation) = + _lineMeasurements[row]; + final index = _displayLayout.lineOrder[row]; + if (_lines[index].text.isEmpty) return primary; + final padding = _linePadding(index).vertical; + return padding + + (primary - + padding + + pronunciation * visibility.dx + + translation * visibility.dy) * + lyricScale; + }); if (previousExtents != null && _layoutVisibility != null && _layoutVisibility != visibility && @@ -3410,12 +3448,14 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { itemExtentBuilder: mornye ? (index, _) => index < lines.length ? _lineExtents![index] - : widget.credits!.heightFor( - context, - constraints.maxWidth - 48, - ) + : widget.credits!.heightFor(context, contentWidth) : null, - padding: EdgeInsets.fromLTRB(24, topPadding, 24, bottomPadding), + padding: EdgeInsets.fromLTRB( + inset, + topPadding, + inset, + bottomPadding, + ), itemCount: lines.length + (widget.credits == null ? 0 : 1), itemBuilder: (context, row) { if (row == lines.length) return widget.credits!; @@ -3557,6 +3597,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { child: child, ), ); + content = FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox(width: layoutWidth, child: content), + ); } return Padding( diff --git a/lib/widgets/mornye_player_slider.dart b/lib/widgets/mornye_player_slider.dart index 6098b052..55aac134 100644 --- a/lib/widgets/mornye_player_slider.dart +++ b/lib/widgets/mornye_player_slider.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; /// A continuous player track with no thumb in either gesture state. class MornyePlayerSlider extends StatelessWidget { + static const horizontalInset = 8.0; + const MornyePlayerSlider({ super.key, required this.value, @@ -34,7 +36,7 @@ class MornyePlayerSlider extends StatelessWidget { showValueIndicator: ShowValueIndicator.never, ), child: Slider( - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: horizontalInset), value: value, max: max, activeColor: activeColor, diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 9db8b0f6..9fbb585d 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -6,7 +6,8 @@ import 'dart:ui' as ui; import 'package:audio_service/audio_service.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show RenderRepaintBoundary; +import 'package:flutter/rendering.dart' + show RenderParagraph, RenderRepaintBoundary; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -96,6 +97,29 @@ void main() { extras: {'source': 'content://library/$id.flac'}, ); + Rect seekTrackBounds(WidgetTester tester) { + final track = find.descendant( + of: find.descendant( + of: find.byType(PlaybackSeekSlider), + matching: find.byType(Slider), + ), + matching: find.byWidgetPredicate( + (widget) => widget is LeafRenderObjectWidget, + ), + ); + final box = tester.renderObject(track); + final theme = SliderTheme.of(tester.element(track)); + final rect = theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + isEnabled: true, + ); + return Rect.fromPoints( + box.localToGlobal(rect.topLeft), + box.localToGlobal(rect.bottomRight), + ); + } + Future pumpNowPlaying( WidgetTester tester, { ThemeData? theme, @@ -438,6 +462,61 @@ void main() { expect(tester.takeException(), isNull); }); + for (final width in [393.0, 768.0]) { + testWidgets('Mornye lyrics fit the timeline without rewrapping ($width)', ( + tester, + ) async { + const original = + 'A longer lyric with words that already wrap onto another row'; + const pronunciation = 'Pronunciation follows the same layout as before'; + const translation = + 'The translated words also keep their existing line breaks'; + metadataOverrides['lyrics'] = + '[x-romaji:1000:${base64.encode(utf8.encode(pronunciation))}]\n' + '[x-translation:1000:${base64.encode(utf8.encode(translation))}]\n' + '[00:01.000]$original\n[00:15.000]Next line'; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: Size(width, 1024), + playback: PlaybackState(updatePosition: const Duration(seconds: 2)), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + + final track = seekTrackBounds(tester); + for (final text in [original, pronunciation, translation]) { + final finder = find.text(text); + final paragraph = tester.renderObject(finder); + final previousLayout = TextPainter( + text: paragraph.text, + textDirection: paragraph.textDirection, + textAlign: paragraph.textAlign, + textScaler: paragraph.textScaler, + locale: paragraph.locale, + )..layout(maxWidth: width - 48); + final selection = TextSelection( + baseOffset: 0, + extentOffset: text.length, + ); + expect( + paragraph.getBoxesForSelection(selection).map((box) => box.toRect()), + previousLayout + .getBoxesForSelection(selection) + .map((box) => box.toRect()), + ); + final bounds = tester.getRect(finder); + expect(bounds.left, closeTo(track.left, 0.01)); + expect(bounds.right, closeTo(track.right, 0.01)); + expect(bounds.height, lessThan(previousLayout.height)); + previousLayout.dispose(); + } + expect(tester.takeException(), isNull); + }); + } + for (final size in [const Size(393, 852), const Size(768, 1024)]) { for (final reducedMotion in [false, true]) { testWidgets( @@ -1496,10 +1575,7 @@ void main() { expect(activeText.style?.fontWeight, FontWeight.bold); expect(activeText.style?.fontSize, 34); expect(tester.getTopLeft(active).dx, tester.getTopLeft(inactive).dx); - expect( - tester.getTopLeft(active).dx, - tester.getTopLeft(find.byType(ListView)).dx + 24, - ); + expect(tester.getTopLeft(active).dx, seekTrackBounds(tester).left); final activeFilters = tester.widgetList( find.ancestor(of: active, matching: find.byType(ImageFiltered)), ); @@ -2140,6 +2216,18 @@ void main() { final compactBounds = tester.getRect(header); expect(compactBounds.left, cover.right + 12); expect(compactBounds.center.dy, cover.center.dy); + final track = seekTrackBounds(tester); + expect(cover.left, closeTo(track.left, 0.01)); + final more = find.descendant( + of: header, + matching: find.byIcon(CupertinoIcons.ellipsis), + ); + expect(tester.getRect(more).right, closeTo(track.right, 0.01)); + final moreButton = find.ancestor( + of: more, + matching: find.byType(IconButton), + ); + expect(tester.getSize(moreButton), const Size(48, 48)); await tester.tap(toggle); await tester.pump(); expect(tester.getRect(header), compactBounds);