From cd7c60cca78282daf15e0123755349a2c73e1221 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:40:21 +0700 Subject: [PATCH] feat(player): show Material lyrics in the artwork area --- lib/screens/now_playing_screen.dart | 244 +++++++------------ test/now_playing_lyrics_transition_test.dart | 110 ++++++++- 2 files changed, 187 insertions(+), 167 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 3b5342ff..87c8f80d 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'dart:ui' show BoxHeightStyle, ImageFilter; import 'package:audio_service/audio_service.dart'; @@ -336,7 +337,6 @@ class NowPlayingScreen extends ConsumerStatefulWidget { } class _NowPlayingScreenState extends ConsumerState { - final PageController _pageController = PageController(); ProviderSubscription>? _mediaItemSub; ProviderSubscription? _lyricsPlayingSub; String? _loadedSource; @@ -380,7 +380,7 @@ class _NowPlayingScreenState extends ConsumerState { _loadMetadataForItem( next.value, // When automatic playback advances while Lyrics is already visible, - // onPageChanged will not run again. Inspect an unresolved SAF URI now + // the lyrics toggle will not run again. Inspect an unresolved SAF URI now // instead of leaving the new track with an empty Lyrics page. inspectUnresolvedContentUri: _currentPage == 1, ); @@ -417,7 +417,6 @@ class _NowPlayingScreenState extends ConsumerState { _lyricsIdleTimer?.cancel(); _mediaItemSub?.close(); _lyricsPlayingSub?.close(); - _pageController.dispose(); _artworkColorsChanged.dispose(); _seekPreview.dispose(); super.dispose(); @@ -733,27 +732,7 @@ class _NowPlayingScreenState extends ConsumerState { artworkAspectRatio: motionRatio, insetArtwork: motionArtwork == null, ) - : PageView( - controller: _pageController, - onPageChanged: (page) { - if (_currentPage != page) { - setState(() => _currentPage = page); - } - if (page == 1) { - _loadMetadataForItem( - ref.read(currentMediaItemProvider).value, - inspectUnresolvedContentUri: true, - ); - } - }, - children: [ - _playerPage(mediaItem, controller, colorScheme), - _lyricsSection( - colorScheme, - isActive: _currentPage == 1, - ), - ], - ), + : _playerPage(mediaItem, controller, colorScheme), ), _autoHidingLyricsControls( _queueSwipeRegion( @@ -806,13 +785,20 @@ class _NowPlayingScreenState extends ConsumerState { ), ) else if (!mornye) - _PageTabBar( - controller: _pageController, - colorScheme: colorScheme, - labels: [ - context.l10n.nowPlayingTabPlayer, - context.l10n.nowPlayingTabLyrics, - ], + ExpressiveIconButton( + key: const ValueKey('material-lyrics-toggle'), + tooltip: _currentPage == 1 + ? context.l10n.nowPlayingTabPlayer + : context.l10n.nowPlayingTabLyrics, + selected: _currentPage == 1, + foregroundColor: _currentPage == 1 + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + backgroundColor: _currentPage == 1 + ? colorScheme.primaryContainer + : null, + icon: const Icon(Icons.lyrics_outlined), + onPressed: _toggleMaterialLyrics, ), if (!_landscape) const SizedBox(height: 8), ], @@ -900,7 +886,7 @@ class _NowPlayingScreenState extends ConsumerState { ); } - /// Swipe up (player content or bottom tab strip) opens the queue sheet; a + /// Swipe up on the player footer opens the queue sheet; a /// downward drag is forwarded to the route's drag-to-dismiss instead. Widget _queueSwipeRegion(ColorScheme colorScheme, Widget child) { final route = ModalRoute.of(context); @@ -1001,16 +987,40 @@ class _NowPlayingScreenState extends ConsumerState { ), ); + Widget visual() => AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + child: _currentPage == 1 + ? KeyedSubtree( + key: const ValueKey('material-player-lyrics'), + child: _lyricsSection(colorScheme, isActive: true), + ) + : LayoutBuilder( + key: const ValueKey('material-player-cover'), + builder: (context, area) => artworkAt( + math.max( + 0.0, + math.min( + 360.0, + math.min(area.maxWidth - 64, area.maxHeight - 24), + ), + ), + ), + ), + ); + // Tablet/landscape: artwork pane left, metadata and controls right, // instead of one narrow column in a sea of empty space. final twoPane = constraints.maxWidth >= 720 && constraints.maxWidth > constraints.maxHeight; if (twoPane) { - final artSize = (constraints.maxHeight - 96).clamp(0.0, 420.0); return Row( children: [ - Expanded(child: artworkAt(artSize)), + Expanded(child: visual()), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), @@ -1024,6 +1034,7 @@ class _NowPlayingScreenState extends ConsumerState { mediaItem, controller, colorScheme, + compact: true, ), ), ), @@ -1033,33 +1044,40 @@ class _NowPlayingScreenState extends ConsumerState { ); } - final artSize = (constraints.maxWidth - 64).clamp(0.0, 360.0); - // Not user-scrollable: a swipe up here opens the queue instead, - // and a swipe down still dismisses the player via the route. - return _queueSwipeRegion( - colorScheme, - SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(vertical: 16), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + children: [ + Expanded(child: visual()), + const SizedBox(height: 12), + _queueSwipeRegion( + colorScheme, + Column( + mainAxisSize: MainAxisSize.min, + children: _metadataAndControls( + mediaItem, + controller, + colorScheme, + ), + ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - artworkAt(artSize), - const SizedBox(height: 32), - ..._metadataAndControls(mediaItem, controller, colorScheme), - ], - ), - ), + ], ), ); }, ); } + void _toggleMaterialLyrics() { + setState(() => _currentPage = _currentPage == 1 ? 0 : 1); + if (_currentPage == 1) { + _loadMetadataForItem( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ); + } + } + void _toggleMornyeLyrics() { _setMornyePage(_currentPage == 1 ? 0 : 1); } @@ -1673,8 +1691,9 @@ class _NowPlayingScreenState extends ConsumerState { List _metadataAndControls( MediaItem mediaItem, MusicPlayerController controller, - ColorScheme colorScheme, - ) { + ColorScheme colorScheme, { + bool compact = false, + }) { return [ Padding( padding: const EdgeInsets.symmetric(horizontal: 28), @@ -1702,16 +1721,19 @@ class _NowPlayingScreenState extends ConsumerState { ExplicitTrackTitle( title: mediaItem.title, explicit: _isExplicit(mediaItem), - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), + style: + (compact + ? Theme.of(context).textTheme.titleLarge + : Theme.of(context).textTheme.headlineSmall) + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), textAlign: TextAlign.center, - maxLines: 2, + maxLines: compact ? 1 : 2, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 6), + SizedBox(height: compact ? 4 : 6), Consumer( builder: (context, ref, _) { final track = ref @@ -1736,7 +1758,7 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ), - const SizedBox(height: 24), + SizedBox(height: compact ? 12 : 24), _PlaybackControls( key: ValueKey(mediaItem.id), mediaId: mediaItem.id, @@ -1745,6 +1767,7 @@ class _NowPlayingScreenState extends ConsumerState { seekPreview: _seekPreview, colorScheme: colorScheme, qualityLabel: _qualityLabel(), + compact: compact, ), ]; } @@ -4641,99 +4664,6 @@ class _MetadataList extends StatelessWidget { } } -class _PageTabBar extends StatelessWidget { - final PageController controller; - final ColorScheme colorScheme; - final List labels; - - const _PageTabBar({ - required this.controller, - required this.colorScheme, - required this.labels, - }); - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: controller, - builder: (context, _) { - double page = 0; - if (controller.hasClients && controller.position.haveDimensions) { - page = controller.page ?? controller.initialPage.toDouble(); - } - return LayoutBuilder( - builder: (context, constraints) { - final tabWidth = constraints.maxWidth / labels.length; - final indicatorWidth = (tabWidth * 0.5).clamp(28.0, 80.0); - final base = - Theme.of(context).textTheme.labelLarge ?? const TextStyle(); - - return SizedBox( - height: 38, - child: Stack( - children: [ - Row( - children: List.generate(labels.length, (i) { - // Distance of this tab from the current page position, - // used to interpolate color/weight as the user swipes. - final t = (1.0 - (page - i).abs()).clamp(0.0, 1.0); - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => controller.animateToPage( - i, - duration: const Duration(milliseconds: 320), - curve: Curves.easeOutCubic, - ), - child: Center( - child: Text( - labels[i], - style: base.copyWith( - fontWeight: FontWeight.lerp( - FontWeight.w500, - FontWeight.bold, - t, - ), - color: Color.lerp( - colorScheme.onSurfaceVariant.withValues( - alpha: 0.55, - ), - colorScheme.primary, - t, - ), - ), - ), - ), - ), - ); - }), - ), - // Sliding underline that tracks the swipe in real time. - Positioned( - bottom: 0, - left: - page.clamp(0, (labels.length - 1).toDouble()) * - tabWidth + - (tabWidth - indicatorWidth) / 2, - child: Container( - width: indicatorWidth, - height: 3, - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ], - ), - ); - }, - ); - }, - ); - } -} - class _QualityBadge extends StatelessWidget { final String? label; final ColorScheme colorScheme; diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 6af968cb..7337e5fe 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -1390,7 +1390,7 @@ void main() { mediaItems.add(item('first')); await tester.pumpAndSettle(); if (layout == 'material') { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); + await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); } else { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } @@ -1757,7 +1757,7 @@ void main() { ); mediaItems.add(item('first')); await tester.pumpAndSettle(); - expect(find.byType(ExpressiveIconButton), findsNWidgets(5)); + expect(find.byType(ExpressiveIconButton), findsNWidgets(6)); for (final element in find.byType(ExpressiveIconButton).evaluate()) { final bounds = tester.getRect(find.byWidget(element.widget)); expect(bounds.left, greaterThanOrEqualTo(0)); @@ -1768,6 +1768,86 @@ void main() { }); } + for (final size in [ + const Size(320, 568), + const Size(430, 932), + const Size(852, 393), + ]) { + testWidgets( + 'Material lyrics share the player and keep controls fixed ($size)', + (tester) async { + final controller = _SeekController(); + metadataOverrides.addAll({ + 'format': 'flac', + 'bit_depth': 16, + 'sample_rate': 44100, + }); + await pumpNowPlaying( + tester, + theme: AppTheme.dark(), + size: size, + controller: controller, + wrapPlayer: (player) => MediaQuery( + data: MediaQueryData( + size: size, + textScaler: TextScaler.linear(1.3), + ), + child: player, + ), + ); + mediaItems.add( + item('first').copyWith( + title: 'A longer track title that needs more than one line', + extras: {'source': '/music/first.flac'}, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(PageView), findsNothing); + final slider = find.byType(PlaybackSeekSlider); + final play = find.byTooltip('Play'); + final toggle = find.byKey(const ValueKey('material-lyrics-toggle')); + final sliderState = tester.state(slider); + final sliderBounds = tester.getRect(slider); + final playBounds = tester.getRect(play); + expect(sliderBounds.bottom, lessThan(size.height)); + expect(play.hitTestable(), findsOneWidget); + + await tester.tap(toggle); + await tester.pump(); + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.getRect(slider), sliderBounds); + expect(tester.getRect(play), playBounds); + expect(tester.state(slider), same(sliderState)); + } + await tester.pumpAndSettle(); + expect(find.text('First lyric').hitTestable(), findsOneWidget); + expect( + find.byKey(const ValueKey('material-player-cover')), + findsNothing, + ); + expect(play.hitTestable(), findsOneWidget); + await tester.tapAt(seekTrackBounds(tester).center); + await tester.pump(); + expect(controller.seeks, hasLength(1)); + expect(controller.seeks.single.inSeconds, closeTo(90, 1)); + controller.completions.single.complete(); + await tester.pumpAndSettle(); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('material-player-cover')), + findsOneWidget, + ); + expect(find.text('First lyric').hitTestable(), findsNothing); + expect(tester.getRect(slider), sliderBounds); + expect(tester.getRect(play), playBounds); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('Mornye player renders Apple-style transport controls', ( tester, ) async { @@ -2576,7 +2656,7 @@ void main() { mediaItems.add(item('first')); await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(-700, 0)); + await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); await tester.pumpAndSettle(); expect(find.text('First lyric'), findsOneWidget); @@ -2787,7 +2867,9 @@ void main() { if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); + await tester.tap( + find.byKey(const ValueKey('material-lyrics-toggle')), + ); } await tester.pumpAndSettle(); for (final seconds in [0, 2, 4, 7, 4]) { @@ -2840,7 +2922,9 @@ void main() { if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); + await tester.tap( + find.byKey(const ValueKey('material-lyrics-toggle')), + ); } await tester.pumpAndSettle(); final lead = tester.widget(find.text('Lead')); @@ -2947,7 +3031,9 @@ void main() { if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); + await tester.tap( + find.byKey(const ValueKey('material-lyrics-toggle')), + ); } await tester.pumpAndSettle(); expect( @@ -3061,7 +3147,7 @@ void main() { if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); + await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); } await tester.pumpAndSettle(); for (final text in ['Original text', 'Romanized text', 'English text']) { @@ -3107,7 +3193,9 @@ void main() { if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); + await tester.tap( + find.byKey(const ValueKey('material-lyrics-toggle')), + ); } await tester.pumpAndSettle(); } @@ -3202,7 +3290,9 @@ void main() { if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); + await tester.tap( + find.byKey(const ValueKey('material-lyrics-toggle')), + ); } await tester.pumpAndSettle(); @@ -3506,7 +3596,7 @@ void main() { mediaItems.add(item('timed')); await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(-700, 0)); + await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); await tester.pumpAndSettle(); final lyric = find.bySemanticsLabel('Short');