diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index bcaa0ff4..58c77228 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1129,17 +1129,6 @@ class _NowPlayingScreenState extends ConsumerState { final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 380); - Widget resize({ - required Widget child, - AlignmentGeometry alignment = Alignment.center, - }) => motion == Duration.zero - ? child - : AnimatedSize( - duration: motion, - curve: Curves.easeInOutCubic, - alignment: alignment, - child: child, - ); return NotificationListener( onNotification: (notification) { if (!showLyrics || @@ -1170,7 +1159,10 @@ class _NowPlayingScreenState extends ConsumerState { compactCoverSize, double.infinity, ); - Widget stage({bool artworkOnly = false}) => LayoutBuilder( + Widget stage({ + bool artworkOnly = false, + double progress = 0, + }) => LayoutBuilder( builder: (context, stage) { final compact = compactStage && !artworkOnly; final expandedArtwork = @@ -1248,150 +1240,128 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ), - AnimatedPositioned( - duration: motion, - curve: Curves.easeInOutCubic, - top: !compact - ? fullBleed - ? -artworkTopInset - : (stage.maxHeight - artHeight) / 2 - : 8, - left: !compact - ? fullBleed - ? 0 - : (stage.maxWidth - artWidth) / 2 - : 28, - width: !compact - ? fullBleed - ? stage.maxWidth - : artWidth - : compactCoverSize, - height: !compact - ? fullBleed - ? motionHeight - : artHeight - : compactCoverSize, - child: HeroMode( - enabled: expandedArtwork || compact, - child: AnimatedSwitcher( - duration: motion, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - layoutBuilder: (current, previous) => Stack( - fit: StackFit.expand, - children: [ - for (final child in previous) - HeroMode( - enabled: false, - child: IgnorePointer(child: child), - ), - ?current, - ], - ), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: !expandedArtwork && !compact - ? null - : KeyedSubtree( - key: ValueKey( - expandedArtwork - ? 'full-player-artwork' - : 'compact-player-artwork', - ), - child: _artworkDragRegion( - context, - Hero( - tag: kNowPlayingArtworkHeroTag, - child: expandedArtwork - ? Consumer( - builder: (context, ref, child) => - AnimatedScale( - scale: - !insetArtwork || - ref.watch( - playbackPlayingProvider, - ) - ? 1 - : 0.73, - duration: motion, - curve: Curves.easeInOutCubic, - child: child, - ), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Color(0x40000000), - blurRadius: 28, - offset: Offset(0, 16), + Positioned.fromRect( + rect: Rect.lerp( + Rect.fromLTWH( + fullBleed ? 0 : (stage.maxWidth - artWidth) / 2, + fullBleed + ? -artworkTopInset + : (stage.maxHeight - artHeight) / 2, + fullBleed ? stage.maxWidth : artWidth, + fullBleed ? motionHeight : artHeight, + ), + const Rect.fromLTWH( + 28, + 8, + compactCoverSize, + compactCoverSize, + ), + progress, + )!, + // Both layers share the header's progress, so reversing a + // transition reuses the live cover instead of inserting a + // duplicate while its previous instance is still fading. + child: Stack( + fit: StackFit.expand, + children: [ + for (final expanded in [true, false]) + if (expanded + ? !fullBleed && (!compact || progress < 1) + : compact || progress > 0) + HeroMode( + key: ValueKey(expanded), + enabled: expanded ? expandedArtwork : compact, + child: IgnorePointer( + ignoring: expanded + ? !expandedArtwork + : !compact, + child: FadeTransition( + opacity: AlwaysStoppedAnimation( + expanded ? 1 - progress : progress, + ), + child: KeyedSubtree( + key: ValueKey( + expanded + ? 'full-player-artwork' + : 'compact-player-artwork', + ), + child: _artworkDragRegion( + context, + Hero( + tag: kNowPlayingArtworkHeroTag, + child: expanded + ? Consumer( + builder: + ( + context, + ref, + child, + ) => AnimatedScale( + scale: + !insetArtwork || + ref.watch( + playbackPlayingProvider, + ) + ? 1 + : 0.73, + duration: motion, + curve: + Curves.easeInOutCubic, + child: child, + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 12, + ), + boxShadow: const [ + BoxShadow( + color: Color( + 0x40000000, + ), + blurRadius: 28, + offset: Offset(0, 16), + ), + ], ), - ], - ), - child: _transitionArtwork( - _expandedArtworkKey, + child: _transitionArtwork( + _expandedArtworkKey, + ClipRRect( + borderRadius: + BorderRadius.circular( + 12, + ), + child: motionArtwork, + ), + ), + ), + ) + : _transitionArtwork( + _compactArtworkKey, ClipRRect( borderRadius: BorderRadius.circular(12), - child: motionArtwork, + child: PlayerArtwork( + artUri: mediaItem.artUri + ?.toString(), + colorScheme: colorScheme, + cacheWidth: + (360 * + MediaQuery.devicePixelRatioOf( + context, + )) + .round(), + ), ), ), - ), - ) - : _transitionArtwork( - _compactArtworkKey, - ClipRRect( - borderRadius: - BorderRadius.circular(12), - child: PlayerArtwork( - artUri: mediaItem.artUri - ?.toString(), - colorScheme: colorScheme, - cacheWidth: - (360 * - MediaQuery.devicePixelRatioOf( - context, - )) - .round(), - ), - ), - ), + ), + ), ), ), ), - ), - ), - ), - Positioned( - left: 28 + compactCoverSize + 12, - right: 28, - top: 8, - child: IgnorePointer( - ignoring: !compact, - child: ExcludeSemantics( - excluding: !compact, - child: AnimatedOpacity( - key: const ValueKey('compact-track-header'), - opacity: compact ? 1 : 0, - duration: motion, - // Pause the hidden marquee without freezing the - // header's own fade-out animation. - child: TickerMode( - enabled: compact, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: compactHeaderHeight, - ), - child: _trackHeader( - mediaItem, - colorScheme, - compact: true, - ), ), - ), - ), - ), + ], ), ), ], @@ -1442,19 +1412,40 @@ class _NowPlayingScreenState extends ConsumerState { Widget content() => Column( children: [ - Expanded(child: stage()), - resize( - child: compactStage - ? const SizedBox(width: double.infinity) - : Padding( - key: _artworkHeaderKey, - padding: const EdgeInsets.fromLTRB(28, 12, 28, 8), - child: AnimatedBuilder( - animation: _artworkColorsChanged, - builder: (context, _) => - _trackHeader(mediaItem, foreground('header')), + Expanded( + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: compactStage ? 1 : 0), + duration: motion, + curve: Curves.easeInOutCubic, + builder: (context, progress, _) => CustomMultiChildLayout( + delegate: _PlayerHeaderLayout( + progress: progress, + compactHeight: compactHeaderHeight, + compactLeft: 28 + compactCoverSize + 12, + ), + children: [ + LayoutId( + id: _PlayerHeaderSlot.stage, + child: stage(progress: progress), + ), + LayoutId( + id: _PlayerHeaderSlot.header, + child: KeyedSubtree( + key: const ValueKey('player-track-header'), + child: AnimatedBuilder( + key: _artworkHeaderKey, + animation: _artworkColorsChanged, + builder: (context, _) => _trackHeader( + mediaItem, + foreground('header'), + compactProgress: progress, + ), + ), ), ), + ], + ), + ), ), _autoHidingLyricsControls( Column( @@ -1496,7 +1487,12 @@ class _NowPlayingScreenState extends ConsumerState { MediaItem mediaItem, ColorScheme colorScheme, { bool compact = false, + double? compactProgress, }) { + final progress = compactProgress ?? (compact ? 1.0 : 0.0); + final actionSize = compactProgress == null + ? (_currentPage == 0 ? 24.0 : 28.0) + : 24 + 4 * progress; return Builder( builder: (context) => Row( children: [ @@ -1521,7 +1517,7 @@ class _NowPlayingScreenState extends ConsumerState { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: compact ? 18 : 22, + fontSize: 22 - 4 * progress, fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), @@ -1535,7 +1531,7 @@ class _NowPlayingScreenState extends ConsumerState { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: compact ? 16 : 20, + fontSize: 20 - 4 * progress, color: colorScheme.onSurface.withValues(alpha: 0.72), ), ), @@ -1549,13 +1545,14 @@ class _NowPlayingScreenState extends ConsumerState { key: ValueKey(mediaItem.id), mediaItem: mediaItem, compact: _currentPage != 0, + iconSize: actionSize, color: colorScheme.onSurface, ), Builder( builder: (buttonContext) => IconButton( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, color: colorScheme.onSurface, - iconSize: _currentPage == 0 ? 24 : 28, + iconSize: actionSize, icon: const Icon(CupertinoIcons.ellipsis), onPressed: () => _showMoreActions( context: context, @@ -2466,6 +2463,56 @@ class _NowPlayingScreenState extends ConsumerState { } } +enum _PlayerHeaderSlot { stage, header } + +/// Keep one live header throughout the cover/lyrics/queue transition. Measuring +/// it at its current width also accommodates accessibility text sizes without +/// moving the transport controls below this layout. +class _PlayerHeaderLayout extends MultiChildLayoutDelegate { + _PlayerHeaderLayout({ + required this.progress, + required this.compactHeight, + required this.compactLeft, + }); + + final double progress; + final double compactHeight; + final double compactLeft; + + @override + void performLayout(Size size) { + final left = 28 + (compactLeft - 28) * progress; + final header = layoutChild( + _PlayerHeaderSlot.header, + BoxConstraints( + minWidth: size.width - left - 28, + maxWidth: size.width - left - 28, + minHeight: compactHeight * progress, + ), + ); + final stageHeight = (size.height - (header.height + 20) * (1 - progress)) + .clamp(0.0, size.height); + layoutChild( + _PlayerHeaderSlot.stage, + BoxConstraints.tight(Size(size.width, stageHeight)), + ); + positionChild(_PlayerHeaderSlot.stage, Offset.zero); + positionChild( + _PlayerHeaderSlot.header, + Offset( + left, + (size.height - header.height - 8) * (1 - progress) + 8 * progress, + ), + ); + } + + @override + bool shouldRelayout(_PlayerHeaderLayout oldDelegate) => + progress != oldDelegate.progress || + compactHeight != oldDelegate.compactHeight || + compactLeft != oldDelegate.compactLeft; +} + class _PlaybackControls extends ConsumerWidget { final String mediaId; final Duration duration; diff --git a/lib/widgets/mornye_player_favorite_button.dart b/lib/widgets/mornye_player_favorite_button.dart index 3c11be1e..42421939 100644 --- a/lib/widgets/mornye_player_favorite_button.dart +++ b/lib/widgets/mornye_player_favorite_button.dart @@ -13,11 +13,13 @@ class MornyePlayerFavoriteButton extends ConsumerStatefulWidget { required this.mediaItem, this.compact = false, this.color, + this.iconSize, }); final MediaItem mediaItem; final bool compact; final Color? color; + final double? iconSize; @override ConsumerState createState() => @@ -69,7 +71,7 @@ class _MornyePlayerFavoriteButtonState ? context.l10n.trackOptionRemoveFromLoved : context.l10n.trackOptionAddToLoved, color: widget.color ?? Theme.of(context).colorScheme.onSurface, - iconSize: widget.compact ? 28 : 24, + iconSize: widget.iconSize ?? (widget.compact ? 28 : 24), onPressed: _saving ? null : _toggle, ), ); diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 63d33d72..6f292f76 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -6,8 +6,7 @@ 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 RenderAnimatedOpacity, RenderRepaintBoundary; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -485,7 +484,7 @@ void main() { expect(queueButton.hitTestable(), findsNothing); expect(tester.getSize(list).height, greaterThan(originalHeight + 150)); expect( - find.byKey(const ValueKey('compact-track-header')).hitTestable(), + find.byKey(const ValueKey('player-track-header')).hitTestable(), findsOneWidget, ); @@ -1501,7 +1500,7 @@ void main() { await tester.pumpAndSettle(); final compactBounds = tester.getRect(compact); expect(compactBounds.size, const Size(72, 72)); - final header = find.byKey(const ValueKey('compact-track-header')); + final header = find.byKey(const ValueKey('player-track-header')); expect(tester.getRect(header).left, compactBounds.right + 12); expect(tester.getRect(header).center.dy, compactBounds.center.dy); await tester.tap(toggle); @@ -1524,7 +1523,7 @@ void main() { ); testWidgets( - 'compact header finishes fading after closing ${lyrics ? 'lyrics' : 'queue'}', + 'one header travels to and from ${lyrics ? 'lyrics' : 'queue'} beside the cover', (tester) async { await pumpNowPlaying( tester, @@ -1536,20 +1535,56 @@ void main() { final toggle = find.byIcon( lyrics ? CupertinoIcons.quote_bubble : CupertinoIcons.list_bullet, ); - final header = find.byKey(const ValueKey('compact-track-header')); - double opacity() => - tester.renderObject(header).opacity.value; - expect(opacity(), 0); + final header = find.byKey(const ValueKey('player-track-header')); + final element = tester.element(header); + final expandedBounds = tester.getRect(header); + final play = find.widgetWithIcon( + MornyePlaybackButton, + CupertinoIcons.play_fill, + ); + final controlsBounds = tester.getRect(play); + final favorite = find.byType(MornyePlayerFavoriteButton); + final favoriteState = tester.state(favorite); for (var visit = 0; visit < 2; visit++) { await tester.tap(toggle); + await tester.pump(); + expect(tester.getRect(header), expandedBounds); + var previous = expandedBounds; + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 80)); + final bounds = tester.getRect(header); + expect(bounds.top, lessThan(previous.top)); + expect(bounds.left, greaterThan(previous.left)); + expect(header.hitTestable(), findsOneWidget); + expect(tester.element(header), same(element)); + expect(tester.state(favorite), same(favoriteState)); + expect(tester.getRect(play), controlsBounds); + previous = bounds; + } await tester.pumpAndSettle(); - expect(opacity(), 1); + final cover = tester.getRect( + find.byKey(const ValueKey('compact-player-artwork')), + ); + final compactBounds = tester.getRect(header); + expect(compactBounds.left, cover.right + 12); + expect(compactBounds.center.dy, cover.center.dy); await tester.tap(toggle); await tester.pump(); - await tester.pump(const Duration(milliseconds: 150)); - expect(opacity(), inExclusiveRange(0, 1)); + expect(tester.getRect(header), compactBounds); + previous = compactBounds; + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 80)); + final bounds = tester.getRect(header); + expect(bounds.top, greaterThan(previous.top)); + expect(bounds.left, lessThan(previous.left)); + expect(header.hitTestable(), findsOneWidget); + expect(tester.element(header), same(element)); + expect(tester.state(favorite), same(favoriteState)); + expect(tester.getRect(play), controlsBounds); + previous = bounds; + } await tester.pumpAndSettle(); - expect(opacity(), 0); + expect(tester.getRect(header), expandedBounds); expect( find.byKey(const ValueKey('full-player-artwork')), findsOneWidget, @@ -1560,6 +1595,84 @@ void main() { ); } + testWidgets('header reverses in place and stays beside cover across panels', ( + tester, + ) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + final header = find.byKey(const ValueKey('player-track-header')); + final element = tester.element(header); + final expandedBounds = tester.getRect(header); + final lyrics = find.byIcon(CupertinoIcons.quote_bubble); + final queue = find.byIcon(CupertinoIcons.list_bullet); + await tester.tap(lyrics); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 160)); + final interruptedBounds = tester.getRect(header); + await tester.tap(lyrics); + await tester.pump(); + expect(tester.getRect(header), interruptedBounds); + await tester.pump(const Duration(milliseconds: 80)); + expect(tester.getRect(header).top, greaterThan(interruptedBounds.top)); + expect(tester.getRect(header).left, lessThan(interruptedBounds.left)); + await tester.pumpAndSettle(); + expect(tester.getRect(header), expandedBounds); + + await tester.tap(lyrics); + await tester.pumpAndSettle(); + final compactBounds = tester.getRect(header); + await tester.tap(queue); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 160)); + expect(tester.getRect(header), compactBounds); + expect(tester.element(header), same(element)); + await tester.pumpAndSettle(); + expect(tester.getRect(header), compactBounds); + expect(tester.takeException(), isNull); + }); + + testWidgets('shared header respects reduced motion and large text', ( + tester, + ) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + wrapPlayer: (player) => Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: true, + textScaler: const TextScaler.linear(1.6), + ), + child: player, + ), + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + final header = find.byKey(const ValueKey('player-track-header')); + final expandedBounds = tester.getRect(header); + final lyrics = find.byIcon(CupertinoIcons.quote_bubble); + await tester.tap(lyrics); + await tester.pump(); + final compactBounds = tester.getRect(header); + final cover = tester.getRect( + find.byKey(const ValueKey('compact-player-artwork')), + ); + expect(compactBounds.left, cover.right + 12); + expect(compactBounds.top, cover.top); + expect(compactBounds.top, lessThan(expandedBounds.top)); + await tester.tap(lyrics); + await tester.pump(); + expect(tester.getRect(header), expandedBounds); + expect(tester.takeException(), isNull); + }); + testWidgets('Mornye lyrics replace artwork while controls stay in place', ( tester, ) async {