From 7a8dadf5f907b2f0d92b9c30c7b1c8d0bf322bd2 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:22:21 +0700 Subject: [PATCH] feat(player): animate artwork transitions and immersive lyrics Enlarge player artwork and transport controls, morph the cover into the lyrics and queue header, and minimize the player back into the mini player. Keep the larger header actions in lyrics and queue only. Use larger lyrics with upper focus and progressive blur. Hide idle lyric controls after three seconds, restore them on interaction, and keep the normal player as the landscape entry page. --- lib/screens/now_playing_screen.dart | 937 ++++++++++++------ lib/widgets/mini_player.dart | 35 +- lib/widgets/mornye_landscape_player.dart | 252 +++-- .../mornye_player_favorite_button.dart | 2 +- test/now_playing_lyrics_transition_test.dart | 635 ++++++++++-- 5 files changed, 1379 insertions(+), 482 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index ac7e1721..c3ff688a 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -30,6 +30,7 @@ import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/overflow_marquee.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -53,11 +54,23 @@ final _log = AppLogger('NowPlaying'); /// the cover visually expands when the player opens. 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 /// settles open or pops based on release position and velocity. class NowPlayingRoute extends PageRoute { - NowPlayingRoute() : super(fullscreenDialog: true); + NowPlayingRoute({this.miniPlayerGeometry}) : super(fullscreenDialog: true); + + /// Read at dismissal so rotation and collapsing navigation cannot leave a + /// stale destination from when the player was opened. + final ({Rect surface, Rect artwork})? Function()? miniPlayerGeometry; + ({Rect bounds, Widget child, bool fullBleed})? Function()? _readArtwork; + ({Rect surface, Rect artwork})? _dismissTarget; + ({Rect bounds, Widget child, bool fullBleed})? _dismissArtwork; + Rect? _dismissStart; + double _dismissStartValue = 1; bool _interactiveTransition = false; int _dragGeneration = 0; @@ -129,6 +142,33 @@ class NowPlayingRoute extends PageRoute { }); } + @override + bool didPop(void result) { + final context = subtreeContext; + if (context != null && + context.isMornye && + !MediaQuery.disableAnimationsOf(context) && + (controller?.value ?? 0) > 0) { + final target = miniPlayerGeometry?.call(); + if (target != null && !target.surface.isEmpty) { + final size = MediaQuery.sizeOf(context); + _dismissStartValue = controller!.value; + final position = _interactiveTransition + ? _dismissStartValue + : Easing.emphasizedDecelerate.transform(_dismissStartValue); + _dismissStart = Rect.fromLTWH( + 0, + size.height * (1 - position), + size.width, + size.height, + ); + _dismissTarget = target; + _dismissArtwork = _readArtwork?.call(); + } + } + return super.didPop(result); + } + @override Widget buildPage( BuildContext context, @@ -145,6 +185,68 @@ class NowPlayingRoute extends PageRoute { Animation secondaryAnimation, Widget child, ) { + final target = _dismissTarget; + if (target != null) { + final progress = Curves.easeInOutCubic.transform( + (1 - animation.value / _dismissStartValue).clamp(0.0, 1.0), + ); + final bounds = Rect.lerp(_dismissStart, target.surface, progress)!; + final size = MediaQuery.sizeOf(context); + final cover = _dismissArtwork; + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fromRect( + rect: bounds, + child: ClipRRect( + key: const ValueKey('player-minimize-surface'), + borderRadius: BorderRadius.circular(28 * progress), + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: size.width, + maxWidth: size.width, + minHeight: size.height, + maxHeight: size.height, + child: Transform.scale( + scale: bounds.width / size.width, + alignment: Alignment.topLeft, + child: Opacity( + opacity: 1 - const Interval(0.15, 0.85).transform(progress), + child: child, + ), + ), + ), + ), + ), + if (cover != null) + Positioned.fromRect( + rect: Rect.lerp(cover.bounds, target.artwork, progress)!, + child: ClipRRect( + key: const ValueKey('player-minimize-artwork'), + borderRadius: BorderRadius.circular( + cover.fullBleed ? 6 * progress : 12 - 6 * progress, + ), + child: cover.fullBleed + ? ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.58, 1], + colors: [ + Colors.white, + Colors.white, + Colors.white.withValues(alpha: progress), + ], + ).createShader(bounds), + child: cover.child, + ) + : cover.child, + ), + ), + ], + ); + } final reversing = animation.status == AnimationStatus.reverse; // Mornye's full-bleed artwork travels with its panel. Material keeps the // independent cover flight back to the mini-player on button/back pop. @@ -219,7 +321,7 @@ class _NowPlayingScreenState extends ConsumerState { ParsedLyrics _lyrics = ParsedLyrics.empty; bool _loadingMeta = false; int _currentPage = 0; - bool _landscapeLyrics = false; + bool _landscape = false; bool _bottomDragForwarding = false; double _bottomDragTotal = 0; bool _queueSheetShowing = false; @@ -228,9 +330,15 @@ class _NowPlayingScreenState extends ConsumerState { String? _failedMotionSource; bool _lyricsControlsHidden = false; double _lyricsScrollDistance = 0; + Timer? _lyricsIdleTimer; + final _lyricsPointers = {}; + bool _lyricsPointerMoved = false; final _artworkHeaderKey = GlobalKey(); final _artworkControlsKey = GlobalKey(); final _artworkVolumeKey = GlobalKey(); + final _expandedArtworkKey = GlobalKey(); + final _compactArtworkKey = GlobalKey(); + final _motionArtworkKey = GlobalKey(); Map _artworkForeground = {}; final _artworkColorsChanged = ValueNotifier(0); @@ -244,7 +352,7 @@ class _NowPlayingScreenState extends ConsumerState { // When automatic playback advances while Lyrics is already visible, // onPageChanged will not run again. Inspect an unresolved SAF URI now // instead of leaving the new track with an empty Lyrics page. - inspectUnresolvedContentUri: _currentPage == 1 || _landscapeLyrics, + inspectUnresolvedContentUri: _currentPage == 1, ), ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -257,19 +365,16 @@ class _NowPlayingScreenState extends ConsumerState { void didChangeDependencies() { super.didChangeDependencies(); final size = MediaQuery.sizeOf(context); - final landscapeLyrics = context.isMornye && size.width > size.height; - if (landscapeLyrics == _landscapeLyrics) return; - _landscapeLyrics = landscapeLyrics; - if (landscapeLyrics) { - _loadMetadataForItem( - ref.read(currentMediaItemProvider).value, - inspectUnresolvedContentUri: true, - ); + _landscape = context.isMornye && size.width > size.height; + if (_landscape || MediaQuery.accessibleNavigationOf(context)) { + _lyricsControlsHidden = false; } + _scheduleLyricsControlsHide(); } @override void dispose() { + _lyricsIdleTimer?.cancel(); _mediaItemSub?.close(); _pageController.dispose(); _artworkColorsChanged.dispose(); @@ -467,8 +572,38 @@ class _NowPlayingScreenState extends ConsumerState { // The Mornye route moves the entire player together. A second, // delayed content fade would make dismissal appear to pause partway down. - final routeAnimation = - ModalRoute.of(context)?.animation ?? kAlwaysCompleteAnimation; + final route = ModalRoute.of(context); + final routeAnimation = route?.animation ?? kAlwaysCompleteAnimation; + if (mornye && route is NowPlayingRoute) { + route._readArtwork = () { + final key = _landscape + ? _expandedArtworkKey + : _currentPage != 0 + ? _compactArtworkKey + : motionArtwork != null + ? _motionArtworkKey + : _expandedArtworkKey; + final box = key.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) return null; + final overlay = route.navigator?.overlay?.context.findRenderObject(); + return ( + bounds: MatrixUtils.transformRect( + box.getTransformTo(overlay), + Offset.zero & box.size, + ), + fullBleed: key == _motionArtworkKey, + child: Theme( + data: MornyeTheme.build(Brightness.dark), + child: key == _compactArtworkKey + ? PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: colorScheme, + ) + : artwork(), + ), + ); + }; + } final contentOpacity = mornye ? kAlwaysCompleteAnimation : routeAnimation.drive( @@ -550,6 +685,7 @@ class _NowPlayingScreenState extends ConsumerState { colorScheme, motionArtwork: artwork(), artworkAspectRatio: motionRatio, + insetArtwork: motionArtwork == null, ) : PageView( controller: _pageController, @@ -573,59 +709,60 @@ class _NowPlayingScreenState extends ConsumerState { ], ), ), - _queueSwipeRegion( - colorScheme, - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (mornye && !_landscapeLyrics) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - tooltip: _currentPage == 1 - ? context.l10n.nowPlayingTabPlayer - : context.l10n.nowPlayingTabLyrics, - isSelected: _currentPage == 1, - color: Colors.white, - style: IconButton.styleFrom( - backgroundColor: _currentPage == 1 - ? Colors.white.withValues(alpha: 0.16) - : Colors.transparent, + _autoHidingLyricsControls( + _queueSwipeRegion( + colorScheme, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (mornye && !_landscape) + FractionallySizedBox( + widthFactor: 0.72, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: _currentPage == 1 + ? context.l10n.nowPlayingTabPlayer + : context.l10n.nowPlayingTabLyrics, + isSelected: _currentPage == 1, + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: _currentPage == 1 + ? Colors.white.withValues(alpha: 0.16) + : Colors.transparent, + ), + icon: const Icon(CupertinoIcons.quote_bubble), + onPressed: _toggleMornyeLyrics, ), - icon: const Icon(CupertinoIcons.quote_bubble), - onPressed: _toggleMornyeLyrics, - ), - IconButton( - tooltip: context.l10n.nowPlayingUpNext, - color: Colors.white, - isSelected: _currentPage == 2, - style: IconButton.styleFrom( - backgroundColor: _currentPage == 2 - ? Colors.white.withValues(alpha: 0.16) - : Colors.transparent, + IconButton( + tooltip: context.l10n.nowPlayingUpNext, + color: Colors.white, + isSelected: _currentPage == 2, + style: IconButton.styleFrom( + backgroundColor: _currentPage == 2 + ? Colors.white.withValues(alpha: 0.16) + : Colors.transparent, + ), + icon: const Icon(CupertinoIcons.list_bullet), + onPressed: () => + _setMornyePage(_currentPage == 2 ? 0 : 2), ), - icon: const Icon(CupertinoIcons.list_bullet), - onPressed: () => setState( - () => _currentPage = _currentPage == 2 ? 0 : 2, - ), - ), + ], + ), + ) + else if (!mornye) + _PageTabBar( + controller: _pageController, + colorScheme: colorScheme, + labels: [ + context.l10n.nowPlayingTabPlayer, + context.l10n.nowPlayingTabLyrics, ], ), - ) - else if (!mornye) - _PageTabBar( - controller: _pageController, - colorScheme: colorScheme, - labels: [ - context.l10n.nowPlayingTabPlayer, - context.l10n.nowPlayingTabLyrics, - ], - ), - if (!_landscapeLyrics) const SizedBox(height: 8), - ], + if (!_landscape) const SizedBox(height: 8), + ], + ), ), ), ], @@ -645,9 +782,7 @@ class _NowPlayingScreenState extends ConsumerState { children: [ MornyeArtworkContrast( enabled: - motionArtwork != null && - !_landscapeLyrics && - _currentPage == 0, + motionArtwork != null && !_landscape && _currentPage == 0, targets: { 'header': _artworkHeaderKey, 'controls': _artworkControlsKey, @@ -662,18 +797,55 @@ class _NowPlayingScreenState extends ConsumerState { artUri: mediaItem.artUri, squareArtwork: squareArtwork, artworkAspectRatio: motionRatio, - artwork: !_landscapeLyrics && _currentPage == 0 - ? Hero(tag: kNowPlayingArtworkHeroTag, child: artwork()) + artwork: + motionArtwork != null && !_landscape && _currentPage == 0 + ? _transitionArtwork( + _motionArtworkKey, + Hero(tag: kNowPlayingArtworkHeroTag, child: artwork()), + ) : null, ), ), - player, + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if (!_canAutoHideLyricsControls) return; + if (_lyricsPointers.isEmpty) _lyricsPointerMoved = false; + _lyricsPointers[event.pointer] = event.position; + _lyricsIdleTimer?.cancel(); + }, + onPointerMove: (event) { + final origin = _lyricsPointers[event.pointer]; + if (origin != null && (event.position - origin).distance > 12) { + _lyricsPointerMoved = true; + } + }, + onPointerUp: (event) => _endLyricsInteraction(event.pointer), + onPointerCancel: (event) => + _endLyricsInteraction(event.pointer, cancelled: true), + child: player, + ), ], ), ), ); } + Widget _transitionArtwork(GlobalKey key, Widget child) { + final route = ModalRoute.of(context); + return AnimatedBuilder( + animation: route?.animation ?? kAlwaysCompleteAnimation, + child: child, + builder: (_, child) => Opacity( + key: key, + opacity: route is NowPlayingRoute && route._dismissArtwork != null + ? 0 + : 1, + child: child, + ), + ); + } + /// Swipe up (player content or bottom tab strip) opens the queue sheet; a /// downward drag is forwarded to the route's drag-to-dismiss instead. Widget _queueSwipeRegion(ColorScheme colorScheme, Widget child) { @@ -832,11 +1004,16 @@ class _NowPlayingScreenState extends ConsumerState { } void _toggleMornyeLyrics() { + _setMornyePage(_currentPage == 1 ? 0 : 1); + } + + void _setMornyePage(int page) { setState(() { - _currentPage = _currentPage == 1 ? 0 : 1; + _currentPage = page; _lyricsControlsHidden = false; _lyricsScrollDistance = 0; }); + _scheduleLyricsControlsHide(); if (_currentPage == 1) { _loadMetadataForItem( ref.read(currentMediaItemProvider).value, @@ -845,18 +1022,87 @@ class _NowPlayingScreenState extends ConsumerState { } } + bool get _canAutoHideLyricsControls => + context.isMornye && + !_landscape && + _currentPage == 1 && + !MediaQuery.accessibleNavigationOf(context); + + void _scheduleLyricsControlsHide() { + _lyricsIdleTimer?.cancel(); + if (!_canAutoHideLyricsControls || + _lyricsControlsHidden || + _lyricsPointers.isNotEmpty) { + return; + } + _lyricsIdleTimer = Timer(const Duration(seconds: 3), () { + if (!mounted || !_canAutoHideLyricsControls) return; + if (ModalRoute.of(context)?.isCurrent == false) { + _scheduleLyricsControlsHide(); + return; + } + setState(() => _lyricsControlsHidden = true); + }); + } + + void _endLyricsInteraction(int pointer, {bool cancelled = false}) { + if (_lyricsPointers.remove(pointer) == null || _lyricsPointers.isNotEmpty) { + return; + } + if (!cancelled && !_lyricsPointerMoved && _lyricsControlsHidden) { + setState(() => _lyricsControlsHidden = false); + } + _scheduleLyricsControlsHide(); + } + + Widget _autoHidingLyricsControls(Widget child) { + if (!context.isMornye) return child; + final hidden = _currentPage == 1 && _lyricsControlsHidden && !_landscape; + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380); + final content = ClipRect( + child: Align( + heightFactor: hidden ? 0 : 1, + child: IgnorePointer( + ignoring: hidden, + child: ExcludeSemantics( + excluding: hidden, + child: AnimatedOpacity( + opacity: hidden ? 0 : 1, + duration: motion, + child: child, + ), + ), + ), + ), + ); + return motion == Duration.zero + ? content + : AnimatedSize( + duration: motion, + curve: Curves.easeInOutCubic, + alignment: Alignment.topCenter, + child: content, + ); + } + Widget _mornyePlayerPage( MediaItem mediaItem, MusicPlayerController controller, ColorScheme colorScheme, { required Widget motionArtwork, double? artworkAspectRatio, + required bool insetArtwork, }) { - final showLyrics = _landscapeLyrics || _currentPage == 1; - final showQueue = !_landscapeLyrics && _currentPage == 2; + final showLyrics = _currentPage == 1; + final showQueue = _currentPage == 2; final compactStage = showLyrics || showQueue; + final artworkTopInset = MediaQuery.paddingOf(context).top + 36; ColorScheme foreground(String region) { - final color = compactStage ? null : _artworkForeground[region]; + final color = compactStage + ? null + : _artworkForeground[region] ?? colorScheme.onSurface; return color == null ? colorScheme : colorScheme.copyWith( @@ -868,10 +1114,21 @@ 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 || - _landscapeLyrics || + _landscape || notification.dragDetails == null || notification.metrics.axis != Axis.vertical) { return false; @@ -893,20 +1150,38 @@ class _NowPlayingScreenState extends ConsumerState { final screenSize = MediaQuery.sizeOf(context); final landscape = screenSize.width > screenSize.height; final scale = MediaQuery.textScalerOf(context).scale(17) / 17; + const compactCoverSize = 72.0; + final compactHeaderHeight = (44 * scale + 4).clamp( + compactCoverSize, + double.infinity, + ); Widget stage({bool artworkOnly = false}) => LayoutBuilder( builder: (context, stage) { final compact = compactStage && !artworkOnly; - final artSize = (stage.maxWidth - 56).clamp( + final expandedArtwork = + artworkOnly || (!compactStage && insetArtwork); + final artSize = (stage.maxWidth - (artworkOnly ? 56 : 48)).clamp( 0.0, - (stage.maxHeight - 24).clamp(0.0, 360.0), + (stage.maxHeight - (artworkOnly ? 24 : 8)).clamp(0.0, 360.0), ); final ratio = artworkAspectRatio ?? 1.0; final artWidth = ratio < 1 ? artSize * ratio : artSize; final artHeight = ratio > 1 ? artSize / ratio : artSize; + final fullBleed = !insetArtwork && !artworkOnly; + // Match the background's bounds even while this cover is hidden. + // Opening/closing a panel then moves the cover between the same + // two positions, with the playback controls painted above it. + final motionHeight = artworkAspectRatio != null + ? (screenSize.width / ratio).clamp( + 0.0, + screenSize.height * 0.75, + ) + : screenSize.height * 0.66; return Stack( + clipBehavior: Clip.none, children: [ Positioned.fill( - top: 76, + top: 8 + compactHeaderHeight + 16, child: IgnorePointer( ignoring: !compact, child: ExcludeSemantics( @@ -958,13 +1233,31 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ), - Positioned( - top: artworkOnly ? (stage.maxHeight - artHeight) / 2 : 8, - left: artworkOnly ? (stage.maxWidth - artWidth) / 2 : 28, - width: artworkOnly ? artWidth : 48, - height: artworkOnly ? artHeight : 48, + 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: artworkOnly || compactStage, + enabled: expandedArtwork || compact, child: AnimatedSwitcher( duration: motion, switchInCurve: Curves.easeInOutCubic, @@ -980,36 +1273,74 @@ class _NowPlayingScreenState extends ConsumerState { ?current, ], ), - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: Tween( - begin: 0.85, - end: 1.0, - ).animate(animation), - child: child, - ), - ), - child: !artworkOnly && !compactStage + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: !expandedArtwork && !compact ? null - : _artworkDragRegion( - context, - Hero( - tag: kNowPlayingArtworkHeroTag, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: artworkOnly - ? motionArtwork - : PlayerArtwork( - artUri: mediaItem.artUri - ?.toString(), - colorScheme: colorScheme, - cacheWidth: - (360 * - MediaQuery.devicePixelRatioOf( - context, - )) - .round(), + : 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), + ), + ], + ), + child: _transitionArtwork( + _expandedArtworkKey, + ClipRRect( + borderRadius: + BorderRadius.circular(12), + child: motionArtwork, + ), + ), + ), + ) + : _transitionArtwork( + _compactArtworkKey, + ClipRRect( + borderRadius: + BorderRadius.circular(12), + child: PlayerArtwork( + artUri: mediaItem.artUri + ?.toString(), + colorScheme: colorScheme, + cacheWidth: + (360 * + MediaQuery.devicePixelRatioOf( + context, + )) + .round(), + ), + ), ), ), ), @@ -1018,20 +1349,31 @@ class _NowPlayingScreenState extends ConsumerState { ), ), Positioned( - left: 88, + left: 28 + compactCoverSize + 12, right: 28, - top: 4, + top: 8, child: IgnorePointer( ignoring: !compact, child: ExcludeSemantics( excluding: !compact, child: AnimatedOpacity( + key: const ValueKey('compact-track-header'), opacity: compact ? 1 : 0, duration: motion, - child: _trackHeader( - mediaItem, - colorScheme, - compact: true, + // 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, + ), + ), ), ), ), @@ -1044,12 +1386,12 @@ class _NowPlayingScreenState extends ConsumerState { // Portrait video must not push the metadata/transport below the // square-cover position. Leave room above the anchored volume row. - final volumeGap = 8 + (constraints.maxHeight - 440).clamp(0.0, 64.0); - // Share the spare space above and below the transport row so it sits - // between the timeline and volume without moving either slider. + final volumeGap = 8 + (constraints.maxHeight - 440).clamp(0.0, 24.0); + // Keep transport close to the timeline; spare height belongs above + // the volume row without moving either slider or the track header. final transportShift = landscape ? 0.0 - : ((volumeGap - 16) / 2).clamp(0.0, 20.0); + : ((volumeGap - 16) / 2).clamp(0.0, 8.0); Widget controls() => AnimatedBuilder( animation: _artworkColorsChanged, builder: (context, _) => _PlaybackControls( @@ -1066,9 +1408,11 @@ class _NowPlayingScreenState extends ConsumerState { if (landscape) { return MornyeLandscapePlayer( + page: _currentPage, + onPageChanged: _setMornyePage, artwork: stage(artworkOnly: true), header: _trackHeader(mediaItem, colorScheme, compact: true), - lyrics: _lyricsSection(colorScheme, isActive: true), + lyrics: _lyricsSection(colorScheme, isActive: showLyrics), queue: MornyePlayerQueue( colorScheme: colorScheme, onShuffleLibrary: () => _shuffleLibrary(controller), @@ -1081,9 +1425,7 @@ class _NowPlayingScreenState extends ConsumerState { Widget content() => Column( children: [ Expanded(child: stage()), - AnimatedSize( - duration: motion, - curve: Curves.easeInOutCubic, + resize( child: compactStage ? const SizedBox(width: double.infinity) : Padding( @@ -1096,39 +1438,21 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ), - AnimatedSize( - duration: motion, - curve: Curves.easeInOutCubic, - alignment: Alignment.topCenter, - child: ClipRect( - child: Align( - heightFactor: showLyrics && _lyricsControlsHidden ? 0 : 1, - child: IgnorePointer( - ignoring: showLyrics && _lyricsControlsHidden, - child: ExcludeSemantics( - excluding: showLyrics && _lyricsControlsHidden, - child: AnimatedOpacity( - opacity: showLyrics && _lyricsControlsHidden ? 0 : 1, - duration: motion, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - controls(), - SizedBox(height: volumeGap - transportShift), - AnimatedBuilder( - animation: _artworkColorsChanged, - builder: (context, _) => MornyeVolumeControl( - key: _artworkVolumeKey, - foreground: foreground('volume').onSurface, - ), - ), - const SizedBox(height: 8), - ], - ), - ), + _autoHidingLyricsControls( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + controls(), + SizedBox(height: volumeGap - transportShift), + AnimatedBuilder( + animation: _artworkColorsChanged, + builder: (context, _) => MornyeVolumeControl( + key: _artworkVolumeKey, + foreground: foreground('volume').onSurface, ), ), - ), + const SizedBox(height: 8), + ], ), ), ], @@ -1171,25 +1495,31 @@ class _NowPlayingScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - ExplicitTrackTitle( - title: mediaItem.title, - explicit: _isExplicit(mediaItem), - maxLines: compact ? 1 : 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: compact ? 15 : 22, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + OverflowMarquee( + resetKey: (mediaItem.id, mediaItem.title), + child: ExplicitTrackTitle( + title: mediaItem.title, + explicit: _isExplicit(mediaItem), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: compact ? 18 : 22, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), const SizedBox(height: 4), - Text( - mediaItem.artist ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: compact ? 13 : 20, - color: colorScheme.onSurface.withValues(alpha: 0.72), + OverflowMarquee( + resetKey: (mediaItem.id, mediaItem.artist), + child: Text( + mediaItem.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: compact ? 16 : 20, + color: colorScheme.onSurface.withValues(alpha: 0.72), + ), ), ), ], @@ -1200,13 +1530,14 @@ class _NowPlayingScreenState extends ConsumerState { MornyePlayerFavoriteButton( key: ValueKey(mediaItem.id), mediaItem: mediaItem, - compact: compact, + compact: _currentPage != 0, color: colorScheme.onSurface, ), Builder( builder: (buttonContext) => IconButton( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, color: colorScheme.onSurface, + iconSize: _currentPage == 0 ? 24 : 28, icon: const Icon(CupertinoIcons.ellipsis), onPressed: () => _showMoreActions( context: context, @@ -1696,7 +2027,7 @@ class _NowPlayingScreenState extends ConsumerState { void _showQueueSheet(ColorScheme colorScheme) { if (context.isMornye) { - setState(() => _currentPage = 2); + _setMornyePage(2); return; } if (_queueSheetShowing) return; @@ -1985,7 +2316,7 @@ class _PlaybackControls extends ConsumerWidget { final position = ref.watch(playbackPositionProvider); final elapsedSeconds = position.inSeconds; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: mornye ? 28 : 16), child: Column( children: [ SliderTheme( @@ -2017,7 +2348,7 @@ class _PlaybackControls extends ConsumerWidget { ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: EdgeInsets.symmetric(horizontal: mornye ? 8 : 12), child: Row( children: [ if (mornye) @@ -2060,120 +2391,129 @@ class _PlaybackControls extends ConsumerWidget { }, ), SizedBox(height: compact ? 8 : transportTopPadding), - Row( - mainAxisAlignment: mornye - ? MainAxisAlignment.spaceEvenly - : MainAxisAlignment.center, - children: [ - if (!mornye) - IconButton( - iconSize: 24, - tooltip: shuffleOn - ? context.l10n.nowPlayingShuffleOn - : context.l10n.nowPlayingPlayInOrder, - color: shuffleOn - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - icon: const Icon(Icons.shuffle), - onPressed: () => controller.setShuffle(!shuffleOn), - ), - if (!mornye) const SizedBox(width: 8), - if (mornye) - MornyePlaybackButton( - icon: CupertinoIcons.backward_fill, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingPreviousTrack, - onPressed: controller.previous, - ) - else - IconButton( - iconSize: 44, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingPreviousTrack, - icon: const Icon(Icons.skip_previous), - onPressed: controller.previous, - ), - SizedBox(width: context.tokens.playerControlGap), - if (mornye) - MornyePlaybackButton( - icon: isPlaying - ? CupertinoIcons.pause_fill - : CupertinoIcons.play_fill, - tooltip: isPlaying - ? context.l10n.actionPause - : context.l10n.tooltipPlay, - color: colorScheme.onSurface, - iconSize: 44, - padding: const EdgeInsets.all(12), - loading: isLoading, - onPressed: () => controller.togglePlayPause(isPlaying), - ) - else - Container( - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, + Padding( + padding: EdgeInsets.symmetric( + horizontal: mornye ? (compact ? 16 : 32) : 0, + ), + child: Row( + mainAxisAlignment: mornye + ? MainAxisAlignment.spaceEvenly + : MainAxisAlignment.center, + children: [ + if (!mornye) + IconButton( + iconSize: 24, + tooltip: shuffleOn + ? context.l10n.nowPlayingShuffleOn + : context.l10n.nowPlayingPlayInOrder, + color: shuffleOn + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + icon: const Icon(Icons.shuffle), + onPressed: () => controller.setShuffle(!shuffleOn), ), - child: IconButton( + if (!mornye) const SizedBox(width: 8), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.backward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + onPressed: controller.previous, + ) + else + IconButton( iconSize: 44, - padding: const EdgeInsets.all(12), - color: colorScheme.onPrimary, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + icon: const Icon(Icons.skip_previous), + onPressed: controller.previous, + ), + if (!mornye) SizedBox(width: context.tokens.playerControlGap), + if (mornye) + MornyePlaybackButton( + icon: isPlaying + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill, tooltip: isPlaying ? context.l10n.actionPause : context.l10n.tooltipPlay, - icon: isLoading - ? const SizedBox.square( - dimension: 32, - child: CircularProgressIndicator(strokeWidth: 3), - ) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), - onPressed: isLoading - ? null - : () => controller.togglePlayPause(isPlaying), + color: colorScheme.onSurface, + iconSize: compact ? 44 : 60, + // Keep the button's height stable as the glyph grows, so the + // timeline and artwork retain their existing positions. + padding: EdgeInsets.all(compact ? 12 : 4), + loading: isLoading, + onPressed: () => controller.togglePlayPause(isPlaying), + ) + else + Container( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: IconButton( + iconSize: 44, + padding: const EdgeInsets.all(12), + color: colorScheme.onPrimary, + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + icon: isLoading + ? const SizedBox.square( + dimension: 32, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: isLoading + ? null + : () => controller.togglePlayPause(isPlaying), + ), ), - ), - SizedBox(width: context.tokens.playerControlGap), - if (mornye) - MornyePlaybackButton( - icon: CupertinoIcons.forward_fill, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingNextTrack, - onPressed: controller.next, - ) - else - IconButton( - iconSize: 44, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingNextTrack, - icon: const Icon(Icons.skip_next), - onPressed: controller.next, - ), - if (!mornye) const SizedBox(width: 8), - if (!mornye) - IconButton( - iconSize: 24, - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.one => - context.l10n.nowPlayingRepeatOne, - AudioServiceRepeatMode.none => - context.l10n.nowPlayingRepeatOff, - _ => context.l10n.nowPlayingRepeatAll, - }, - color: repeatMode == AudioServiceRepeatMode.none - ? colorScheme.onSurfaceVariant - : colorScheme.primary, - icon: Icon( - repeatMode == AudioServiceRepeatMode.one - ? Icons.repeat_one - : Icons.repeat, + if (!mornye) SizedBox(width: context.tokens.playerControlGap), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.forward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + onPressed: controller.next, + ) + else + IconButton( + iconSize: 44, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + icon: const Icon(Icons.skip_next), + onPressed: controller.next, ), - onPressed: () => controller.setRepeatMode(switch (repeatMode) { - AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, - AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }), - ), - ], + if (!mornye) const SizedBox(width: 8), + if (!mornye) + IconButton( + iconSize: 24, + tooltip: switch (repeatMode) { + AudioServiceRepeatMode.one => + context.l10n.nowPlayingRepeatOne, + AudioServiceRepeatMode.none => + context.l10n.nowPlayingRepeatOff, + _ => context.l10n.nowPlayingRepeatAll, + }, + color: repeatMode == AudioServiceRepeatMode.none + ? colorScheme.onSurfaceVariant + : colorScheme.primary, + icon: Icon( + repeatMode == AudioServiceRepeatMode.one + ? Icons.repeat_one + : Icons.repeat, + ), + onPressed: () => controller.setRepeatMode( + switch (repeatMode) { + AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }, + ), + ), + ], + ), ), ], ); @@ -2362,7 +2702,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { void _measureMornyeLines(double width) { final style = Theme.of(context).textTheme.headlineSmall?.copyWith( height: 1.3, - fontSize: 28, + fontSize: _mornyeLyricFontSize, fontWeight: FontWeight.bold, ); final scaler = MediaQuery.textScalerOf(context); @@ -2407,7 +2747,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final target = extents.take(index).fold(0.0, (sum, extent) => sum + extent) + padding - - (position.viewportDimension - extents[index]) / 2; + (position.viewportDimension - extents[index]).clamp( + 0.0, + double.infinity, + ) * + _mornyeLyricFocusAlignment; final offset = target.clamp( position.minScrollExtent, position.maxScrollExtent, @@ -2511,12 +2855,19 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { 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; return ListView.builder( controller: _scroll, itemExtentBuilder: mornye ? (index, _) => _lineExtents![index] : null, - padding: EdgeInsets.fromLTRB(24, centerPadding, 24, centerPadding), + padding: EdgeInsets.fromLTRB(24, centerPadding, 24, bottomPadding), itemCount: lines.length, itemBuilder: (context, index) { final line = lines[index]; @@ -2556,7 +2907,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { : Theme.of(context).textTheme.titleLarge) ?.copyWith( height: context.tokens.lyricsLineHeight, - fontSize: mornye ? 28 : null, + fontSize: mornye ? _mornyeLyricFontSize : null, fontWeight: mornye || isActive ? FontWeight.bold : FontWeight.w500, @@ -2579,15 +2930,17 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ); if (mornye) { final distance = (index - active).abs(); + // Nearby lines need visible defocus at the larger lyric size; + // progressively soften lines further from the current one. final sigma = !blurLyrics || isActive ? 0.0 : active < 0 - ? 2.8 + ? 4.8 : distance == 1 - ? 0.6 + ? 2.4 : distance == 2 - ? 1.8 - : 2.8; + ? 3.6 + : 4.8; content = TweenAnimationBuilder( tween: Tween(end: sigma), duration: motion, @@ -2845,7 +3198,7 @@ class _WordHighlightedLyricLineState final style = (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) .copyWith( - fontSize: context.isMornye ? 28 : null, + fontSize: context.isMornye ? _mornyeLyricFontSize : null, height: context.tokens.lyricsLineHeight, fontWeight: FontWeight.bold, ); diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 1907a33a..43de1a2f 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -27,6 +27,22 @@ class MiniPlayer extends ConsumerStatefulWidget { } class _MiniPlayerState extends ConsumerState { + final _surfaceKey = GlobalKey(); + final _artworkKey = GlobalKey(); + + Rect? _bounds(GlobalKey key) { + final box = key.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) return null; + final overlay = Navigator.of( + context, + rootNavigator: true, + ).overlay?.context.findRenderObject(); + return MatrixUtils.transformRect( + box.getTransformTo(overlay), + Offset.zero & box.size, + ); + } + // Hides the bar in the frames between a swipe-dismiss and the stopped // service clearing the media item (a dismissed Dismissible must leave the // tree immediately). Playing the same track again shows the bar normally. @@ -69,6 +85,7 @@ class _MiniPlayerState extends ConsumerState { controller.stop(); }, child: DecoratedBox( + key: _surfaceKey, position: DecorationPosition.foreground, decoration: BoxDecoration( border: mornye @@ -85,10 +102,19 @@ class _MiniPlayerState extends ConsumerState { : settingsGroupColor(context).withValues(alpha: 0.72), child: InkWell( onTap: () { - Navigator.of( - context, - rootNavigator: true, - ).push(NowPlayingRoute()); + Navigator.of(context, rootNavigator: true).push( + NowPlayingRoute( + miniPlayerGeometry: mornye + ? () { + if (!mounted) return null; + final surface = _bounds(_surfaceKey); + final artwork = _bounds(_artworkKey); + if (surface == null || artwork == null) return null; + return (surface: surface, artwork: artwork); + } + : null, + ), + ); }, child: Column( mainAxisSize: MainAxisSize.min, @@ -108,6 +134,7 @@ class _MiniPlayerState extends ConsumerState { Hero( tag: kNowPlayingArtworkHeroTag, child: ClipRRect( + key: _artworkKey, borderRadius: BorderRadius.circular(6), child: SizedBox( width: context.tokens.coverMini, diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart index 21385f9f..0b10416e 100644 --- a/lib/widgets/mornye_landscape_player.dart +++ b/lib/widgets/mornye_landscape_player.dart @@ -4,8 +4,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -/// Landscape keeps the cover in place while the right pane switches between -/// lyrics, the queue, and controls revealed by a tap. +/// Landscape opens on the player. Lyrics can hide their bottom actions until +/// the user touches the player, while the cover and header stay in place. class MornyeLandscapePlayer extends StatefulWidget { const MornyeLandscapePlayer({ super.key, @@ -15,6 +15,8 @@ class MornyeLandscapePlayer extends StatefulWidget { required this.queue, required this.controls, required this.volume, + required this.page, + required this.onPageChanged, }); final Widget artwork; @@ -23,6 +25,8 @@ class MornyeLandscapePlayer extends StatefulWidget { final Widget queue; final Widget controls; final Widget volume; + final int page; + final ValueChanged onPageChanged; @override State createState() => _MornyeLandscapePlayerState(); @@ -30,44 +34,50 @@ class MornyeLandscapePlayer extends StatefulWidget { class _MornyeLandscapePlayerState extends State { Timer? _hideTimer; - bool _controlsVisible = false; - bool _queueVisible = false; + bool _actionsVisible = true; @override void didChangeDependencies() { super.didChangeDependencies(); if (MediaQuery.accessibleNavigationOf(context)) { - _controlsVisible = true; + _actionsVisible = true; _hideTimer?.cancel(); + } else { + _scheduleHide(); + } + } + + @override + void didUpdateWidget(MornyeLandscapePlayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.page != widget.page) { + _actionsVisible = true; + _scheduleHide(); } } void _scheduleHide() { _hideTimer?.cancel(); - if (!_controlsVisible || MediaQuery.accessibleNavigationOf(context)) return; - _hideTimer = Timer(const Duration(seconds: 4), () { + if (widget.page != 1 || + !_actionsVisible || + MediaQuery.accessibleNavigationOf(context)) { + return; + } + _hideTimer = Timer(const Duration(seconds: 3), () { if (!mounted) return; if (ModalRoute.of(context)?.isCurrent == false) { _scheduleHide(); return; } - setState(() => _controlsVisible = false); + setState(() => _actionsVisible = false); }); } void _reveal() { - if (!_controlsVisible) setState(() => _controlsVisible = true); + if (!_actionsVisible) setState(() => _actionsVisible = true); _scheduleHide(); } - void _showPanel({required bool queue}) { - _hideTimer?.cancel(); - setState(() { - _queueVisible = queue; - _controlsVisible = false; - }); - } - @override void dispose() { _hideTimer?.cancel(); @@ -76,102 +86,140 @@ class _MornyeLandscapePlayerState extends State { @override Widget build(BuildContext context) => Listener( - onPointerDown: (_) => _hideTimer?.cancel(), + onPointerDown: (_) { + _hideTimer?.cancel(); + if (!_actionsVisible) setState(() => _actionsVisible = true); + }, onPointerUp: (_) => _scheduleHide(), onPointerCancel: (_) => _scheduleHide(), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: _reveal, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: Row( - children: [ - Expanded(child: widget.artwork), - Expanded( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: widget.header, - ), - const SizedBox(height: 8), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) => AnimatedSwitcher( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - layoutBuilder: (current, previous) => Stack( - fit: StackFit.expand, - children: [ - for (final child in previous) - IgnorePointer( - child: ExcludeSemantics(child: child), - ), - ?current, - ], - ), - child: _controlsVisible - ? SingleChildScrollView( - key: const ValueKey('landscape-controls'), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Row( + children: [ + Expanded(child: widget.artwork), + Expanded( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: widget.header, + ), + const SizedBox(height: 8), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) => Stack( + fit: StackFit.expand, + children: [ + AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + IgnorePointer( + child: ExcludeSemantics(child: child), + ), + ?current, + ], + ), + child: widget.page == 0 + ? SingleChildScrollView( + key: const ValueKey('landscape-controls'), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Column( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + widget.controls, + widget.volume, + const SizedBox(height: 48), + ], + ), ), - child: Column( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - widget.controls, - widget.volume, - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, + ) + : Padding( + key: ValueKey(widget.page), + padding: EdgeInsets.only( + bottom: widget.page == 2 ? 48 : 0, + ), + child: widget.page == 2 + ? widget.queue + : widget.lyrics, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 48, + child: GestureDetector( + key: const ValueKey('landscape-actions-reveal'), + behavior: HitTestBehavior.opaque, + onTap: _reveal, + child: IgnorePointer( + ignoring: !_actionsVisible, + child: ExcludeSemantics( + excluding: !_actionsVisible, + child: AnimatedOpacity( + opacity: _actionsVisible ? 1 : 0, + duration: + MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: widget.page == 1 + ? context.l10n.nowPlayingTabPlayer + : context + .l10n + .nowPlayingTabLyrics, + isSelected: widget.page == 1, + icon: const Icon( + CupertinoIcons.quote_bubble, + ), + onPressed: () => widget.onPageChanged( + widget.page == 1 ? 0 : 1, + ), ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - IconButton( - tooltip: context - .l10n - .nowPlayingTabLyrics, - icon: const Icon( - CupertinoIcons.quote_bubble, - ), - onPressed: () => - _showPanel(queue: false), - ), - IconButton( - tooltip: - context.l10n.nowPlayingUpNext, - icon: const Icon( - CupertinoIcons.list_bullet, - ), - onPressed: () => - _showPanel(queue: true), - ), - ], + IconButton( + tooltip: + context.l10n.nowPlayingUpNext, + icon: const Icon( + CupertinoIcons.list_bullet, + ), + isSelected: widget.page == 2, + onPressed: () => widget.onPageChanged( + widget.page == 2 ? 0 : 2, + ), ), - ), - ], + ], + ), ), ), - ) - : KeyedSubtree( - key: ValueKey(_queueVisible), - child: _queueVisible - ? widget.queue - : widget.lyrics, ), - ), + ), + ), + ), + ], ), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ), ); diff --git a/lib/widgets/mornye_player_favorite_button.dart b/lib/widgets/mornye_player_favorite_button.dart index cf889713..3c11be1e 100644 --- a/lib/widgets/mornye_player_favorite_button.dart +++ b/lib/widgets/mornye_player_favorite_button.dart @@ -69,7 +69,7 @@ class _MornyePlayerFavoriteButtonState ? context.l10n.trackOptionRemoveFromLoved : context.l10n.trackOptionAddToLoved, color: widget.color ?? Theme.of(context).colorScheme.onSurface, - iconSize: widget.compact ? 20 : 24, + 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 15342472..8b565c6b 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 RenderAnimatedOpacity, RenderRepaintBoundary; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -33,6 +34,7 @@ import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/mornye_player_background.dart'; import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart'; +import 'package:spotiflac_android/widgets/mini_player.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -184,6 +186,84 @@ void main() { }, ); + for (final reducedMotion in [false, true]) { + testWidgets( + 'static cover shrinks on pause without moving controls (reduced motion: $reducedMotion)', + (tester) async { + const longTitle = + 'I Do Not Want to Talk About It (A Long Album Version)'; + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + tester.view.viewPadding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + final playbackEvents = StreamController.broadcast(); + addTearDown(playbackEvents.close); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + playbackEvents: playbackEvents.stream, + wrapPlayer: (player) => Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reducedMotion), + child: player, + ), + ), + ); + mediaItems.add(item('first').copyWith(title: longTitle)); + await tester.pumpAndSettle(); + playbackEvents.add(PlaybackState(playing: true)); + await tester.pumpAndSettle(); + + final cover = find.byType(MornyePlayerArtwork); + final playingCover = tester.getRect(cover); + final titleFinder = find + .text(longTitle) + .hitTestable(at: Alignment.centerLeft) + .first; + final title = tester.getRect(titleFinder); + final volume = tester.getRect(find.byType(MornyeVolumeControl)); + expect(playingCover.width, playingCover.height); + expect(playingCover.width, greaterThanOrEqualTo(393 * 0.84)); + expect(playingCover.left, greaterThan(0)); + expect(playingCover.bottom, lessThan(title.top)); + expect( + find.descendant( + of: find.byType(MornyePlayerBackground), + matching: cover, + ), + findsNothing, + ); + + playbackEvents.add(PlaybackState(playing: false)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + if (!reducedMotion) { + expect( + tester.getRect(cover).width, + inExclusiveRange(playingCover.width * 0.73, playingCover.width), + ); + } + await tester.pumpAndSettle(); + final pausedCover = tester.getRect(cover); + expect(pausedCover.width, closeTo(playingCover.width * 0.73, 0.01)); + expect(pausedCover.center.dx, closeTo(playingCover.center.dx, 0.01)); + expect(pausedCover.center.dy, closeTo(playingCover.center.dy, 0.01)); + expect(tester.getRect(titleFinder), title); + expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + + playbackEvents.add(PlaybackState(playing: true)); + await tester.pumpAndSettle(); + expect(tester.getRect(cover).width, closeTo(playingCover.width, 0.01)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + testWidgets('video contrast updates controls without rebuilding artwork', ( tester, ) async { @@ -239,7 +319,7 @@ void main() { await tester.pumpWidget(const SizedBox()); }); - testWidgets('opening Mornye lyrics centers the current wrapped line', ( + testWidgets('Mornye lyrics keep wrapped, first and last lines at upper focus', ( tester, ) async { final lyrics = List.generate(100, (index) { @@ -250,13 +330,16 @@ void main() { return '[$time]Line $index with enough words to wrap across several rows'; }).join('\n'); metadataOverrides = {'lyrics': lyrics}; + final playback = StreamController(); + addTearDown(playback.close); await pumpNowPlaying( tester, theme: MornyeTheme.build(Brightness.dark), size: const Size(393, 780), - playback: PlaybackState(updatePosition: const Duration(seconds: 140)), + playbackEvents: playback.stream, ); mediaItems.add(item('many')); + playback.add(PlaybackState(updatePosition: const Duration(seconds: 140))); await tester.pumpAndSettle(); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); await tester.pumpAndSettle(); @@ -264,15 +347,32 @@ void main() { 'Line 70 with enough words to wrap across several rows', ); expect(current, findsOneWidget); - final list = find.ancestor(of: current, matching: find.byType(ListView)); - expect(tester.getCenter(current).dy, closeTo(tester.getCenter(list).dy, 2)); + final list = find.byType(ListView); + 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)); + } + + expectUpperFocus(current); await tester.drag(list, const Offset(0, 200)); await tester.pumpAndSettle(); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); await tester.pumpAndSettle(); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); await tester.pumpAndSettle(); - expect(tester.getCenter(current).dy, closeTo(tester.getCenter(list).dy, 2)); + expectUpperFocus(current); + for (final index in [99, 0]) { + playback.add(PlaybackState(updatePosition: Duration(seconds: index * 2))); + await tester.pumpAndSettle(); + expectUpperFocus( + find.text('Line $index with enough words to wrap across several rows'), + ); + } expect(tester.takeException(), isNull); }); @@ -338,6 +438,116 @@ void main() { expect(tester.takeException(), isNull); }); + for (final reducedMotion in [false, true]) { + testWidgets( + 'lyrics hide controls after three idle seconds and restore on tap (reduced motion: $reducedMotion)', + (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: reducedMotion), + child: player, + ), + ), + ); + mediaItems.add(item('many')); + await tester.pumpAndSettle(); + final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); + final queueButton = find.byIcon(CupertinoIcons.list_bullet); + final play = find.widgetWithIcon( + MornyePlaybackButton, + CupertinoIcons.play_fill, + ); + final volume = find.byType(MornyeVolumeControl); + await tester.tap(lyricsButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2900)); + expect(play.hitTestable(), findsOneWidget); + final list = find.byType(ListView); + final originalHeight = tester.getSize(list).height; + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(play.hitTestable(), findsNothing); + expect(volume.hitTestable(), findsNothing); + expect(lyricsButton.hitTestable(), findsNothing); + expect(queueButton.hitTestable(), findsNothing); + expect(tester.getSize(list).height, greaterThan(originalHeight + 150)); + expect( + find.byKey(const ValueKey('compact-track-header')).hitTestable(), + findsOneWidget, + ); + + Future reveal() async { + final bounds = tester.getRect(list); + await tester.tapAt(Offset(bounds.right - 6, bounds.top + 10)); + await tester.pumpAndSettle(); + expect(play.hitTestable(), findsOneWidget); + expect(lyricsButton.hitTestable(), findsOneWidget); + } + + await reveal(); + final slider = find.descendant( + of: volume, + matching: find.byType(Slider), + ); + final touch = await tester.startGesture(tester.getCenter(slider)); + await tester.pump(const Duration(seconds: 5)); + expect(volume.hitTestable(), findsOneWidget); + await touch.up(); + await tester.pump(const Duration(milliseconds: 2900)); + expect(volume.hitTestable(), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(volume.hitTestable(), findsNothing); + + await reveal(); + await tester.tap(queueButton.hitTestable()); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 4)); + expect(play.hitTestable(), findsOneWidget); + expect(queueButton.hitTestable(), findsOneWidget); + await tester.tap(queueButton.hitTestable()); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 4)); + expect(play.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'accessible navigation keeps lyric controls available while idle', + (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(accessibleNavigation: true), + child: player, + ), + ), + ); + mediaItems.add(item('many')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + expect(find.byType(MornyeVolumeControl).hitTestable(), findsOneWidget); + expect( + find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('Mornye player background does not reveal the page below', ( tester, ) async { @@ -397,14 +607,16 @@ void main() { ); mediaItems.add(item('first')); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + final moreButton = find.byIcon(CupertinoIcons.ellipsis).hitTestable(); + final anchor = tester.getRect(moreButton); + await tester.tap(moreButton); await tester.pump(); await tester.pump(const Duration(milliseconds: 40)); final panel = find.byType(MornyePlayerActionsSheet); final enteringWidth = tester.getSize(panel).width; await tester.pumpAndSettle(); expect(tester.getSize(panel).width, enteringWidth); - expect(tester.getRect(panel).bottom, lessThan(780 - 16)); + expect(tester.getRect(panel).bottom, lessThan(anchor.top)); expect(tester.getRect(panel).width, 320); expect(find.byType(BottomSheet), findsNothing); expect(Theme.of(tester.element(panel)).brightness, Brightness.dark); @@ -415,6 +627,17 @@ void main() { expect(find.byIcon(CupertinoIcons.square_stack), findsOneWidget); expect(find.byIcon(CupertinoIcons.moon_zzz), findsOneWidget); expect(find.byIcon(CupertinoIcons.gear_alt), findsNothing); + expect( + find.text( + AppLocalizations.of(tester.element(panel)).collectionAddToPlaylist, + ), + findsOneWidget, + ); + expect(find.text('Go to Artist'), findsOneWidget); + expect(find.text('Favorite'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + expect(tester.widget(find.text('Go to Album')).style?.fontSize, 15); + await tester.ensureVisible(find.text('Sleep timer')); await tester.tap(find.text('Sleep timer')); await tester.pumpAndSettle(); expect( @@ -596,6 +819,20 @@ void main() { container.read(libraryCollectionsProvider).loved.single.track.id, 'first', ); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Favorited')); + await tester.pumpAndSettle(); + expect(container.read(libraryCollectionsProvider).loved, isEmpty); + expect(star, findsOneWidget); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Favorite')); + await tester.pumpAndSettle(); + expect( + container.read(libraryCollectionsProvider).loved.single.track.id, + 'first', + ); await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); await tester.pumpAndSettle(); expect( @@ -735,7 +972,7 @@ void main() { final activeText = tester.widget(active); expect(activeText.textAlign, TextAlign.start); expect(activeText.style?.fontWeight, FontWeight.bold); - expect(activeText.style?.fontSize, 28); + expect(activeText.style?.fontSize, 34); expect(tester.getTopLeft(active).dx, tester.getTopLeft(inactive).dx); expect( tester.getTopLeft(active).dx, @@ -752,7 +989,7 @@ void main() { inactiveFilters.any( (filter) => filter.enabled && - filter.imageFilter == ImageFilter.blur(sigmaX: 0.6, sigmaY: 0.6), + filter.imageFilter == ImageFilter.blur(sigmaX: 2.4, sigmaY: 2.4), ), isTrue, ); @@ -936,7 +1173,7 @@ void main() { }); testWidgets( - 'Mornye landscape shows lyrics automatically without bottom actions', + 'Mornye landscape opens the player and reveals hidden lyric actions on a bottom tap', (tester) async { await pumpNowPlaying( tester, @@ -960,21 +1197,19 @@ void main() { final header = find.text('First').hitTestable(); final lyric = find.text('First lyric').hitTestable(); expect(header, findsOneWidget); - expect(lyric, findsOneWidget); + expect(lyric, findsNothing); expect(tester.getRect(header).left, greaterThan(artRect.right)); - expect(tester.getRect(lyric).left, greaterThan(artRect.right)); + expect(volume.hitTestable(), findsOneWidget); expect( - tester.getRect(lyric).top, - greaterThan(tester.getRect(header).bottom), + find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), + findsOneWidget, + ); + expect( + find.byIcon(CupertinoIcons.list_bullet).hitTestable(), + findsOneWidget, ); - expect(volume, findsNothing); - expect(find.byIcon(CupertinoIcons.quote_bubble), findsNothing); - expect(find.byIcon(CupertinoIcons.list_bullet), findsNothing); expect(tester.takeException(), isNull); - await tester.tap(artwork); - await tester.pumpAndSettle(); - expect(volume.hitTestable(), findsOneWidget); expect( find.byIcon(CupertinoIcons.play_fill).hitTestable(), findsOneWidget, @@ -991,8 +1226,39 @@ void main() { await drag.up(); await tester.pump(const Duration(seconds: 4)); await tester.pumpAndSettle(); + expect(volume.hitTestable(), findsOneWidget); + + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble).hitTestable()); + await tester.pumpAndSettle(); expect(volume, findsNothing); expect(find.text('First lyric').hitTestable(), findsOneWidget); + expect(tester.getRect(lyric).left, greaterThan(artRect.right)); + expect( + tester.getRect(lyric).top, + greaterThan(tester.getRect(header).bottom), + ); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), + findsNothing, + ); + expect( + find.byIcon(CupertinoIcons.list_bullet).hitTestable(), + findsNothing, + ); + await tester.tap(find.byKey(const ValueKey('landscape-actions-reveal'))); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), + findsOneWidget, + ); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble).hitTestable()); + await tester.pumpAndSettle(); + expect(volume.hitTestable(), findsOneWidget); + expect(lyric, findsNothing); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble).hitTestable()); + await tester.pumpAndSettle(); mediaItems.add(item('second')); await tester.pumpAndSettle(); @@ -1017,69 +1283,275 @@ void main() { }, ); - testWidgets('Mornye cover stays attached throughout opening and closing', ( - tester, - ) async { - tester.view.padding = FakeViewPadding(top: 59, bottom: 34); - tester.view.viewPadding = FakeViewPadding(top: 59, bottom: 34); - addTearDown(tester.view.resetPadding); - addTearDown(tester.view.resetViewPadding); - await pumpNowPlaying( - tester, - theme: MornyeTheme.build(Brightness.dark), - size: const Size(393, 852), - wrapPlayer: (_) => Consumer( - builder: (context, ref, _) { - ref.watch(currentMediaItemProvider); - return Scaffold( - body: Align( - alignment: Alignment.bottomLeft, - child: TextButton( - onPressed: () => Navigator.of(context).push(NowPlayingRoute()), - child: const Hero( - tag: kNowPlayingArtworkHeroTag, - child: SizedBox.square(dimension: 38, child: Text('Open')), + testWidgets( + 'Mornye motion cover stays attached throughout opening and closing', + (tester) async { + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + tester.view.viewPadding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + motionArtwork: const MotionArtwork('file:///cover.mp4', aspectRatio: 1), + wrapPlayer: (_) => Consumer( + builder: (context, ref, _) { + ref.watch(currentMediaItemProvider); + return Scaffold( + body: Align( + alignment: Alignment.bottomLeft, + child: TextButton( + onPressed: () => + Navigator.of(context).push(NowPlayingRoute()), + child: const Hero( + tag: kNowPlayingArtworkHeroTag, + child: SizedBox.square(dimension: 38, child: Text('Open')), + ), + ), + ), + ); + }, + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open')); + await tester.pump(); + + void expectAttached() { + final panel = tester.getRect(find.byType(MornyePlayerBackground)); + final artwork = tester.getRect( + find.descendant( + of: find.byType(MornyePlayerBackground), + matching: find.byType(MornyePlayerArtwork), + ), + ); + expect(artwork.left, closeTo(panel.left, 0.01)); + expect(artwork.top, closeTo(panel.top, 0.01)); + expect(artwork.width, closeTo(393, 0.01)); + expect(artwork.height, closeTo(393, 0.01)); + } + + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expectAttached(); + } + await tester.pumpAndSettle(); + expectAttached(); + Navigator.of(tester.element(find.byType(NowPlayingScreen))).pop(); + await tester.pump(); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expectAttached(); + expect( + tester.getTopLeft(find.byType(MornyePlayerBackground)).dy, + greaterThan(0), + ); + } + await tester.pumpAndSettle(); + expect(find.byType(NowPlayingScreen), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + for (final motion in [false, true]) { + for (final drag in [false, true]) { + testWidgets( + 'Mornye minimizes into the current mini player (motion: $motion, drag: $drag)', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + playback: PlaybackState(playing: true), + motionArtwork: motion + ? const MotionArtwork('file:///cover.mp4', aspectRatio: 1) + : null, + wrapPlayer: (_) => const Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.fromLTRB(12, 0, 12, 90), + child: MiniPlayer(), ), ), ), ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + final mini = find.byType(MiniPlayer); + final miniCover = find.descendant( + of: mini, + matching: find.byType(Hero), + ); + final destination = tester.getRect(miniCover); + await tester.tap( + find.descendant(of: mini, matching: find.text('First')), + ); + await tester.pumpAndSettle(); + final player = find.byType(NowPlayingScreen); + final route = + ModalRoute.of(tester.element(player))! as NowPlayingRoute; + final fullCover = find.descendant( + of: player, + matching: find.byType(MornyePlayerArtwork), + ); + if (drag) { + route.startDrag(); + route.updateDrag( + DragUpdateDetails( + globalPosition: const Offset(0, 300), + delta: const Offset(0, 300), + primaryDelta: 300, + ), + 852, + ); + await tester.pump(); + } + final releasedCover = tester.getRect(fullCover); + final releasedPanel = tester.getRect( + find.byType(MornyePlayerBackground), + ); + if (drag) { + route.endDrag(DragEndDetails(primaryVelocity: 0), 852); + } else { + Navigator.of(tester.element(player)).pop(); + } + await tester.pump(); + final flyingCover = find.byKey( + const ValueKey('player-minimize-artwork'), + ); + final surface = find.byKey(const ValueKey('player-minimize-surface')); + expect(tester.getRect(flyingCover), releasedCover); + expect(tester.getRect(surface), releasedPanel); + var previousDistance = + (releasedCover.center - destination.center).distance; + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 25)); + final bounds = tester.getRect(flyingCover); + final distance = (bounds.center - destination.center).distance; + expect(distance, lessThan(previousDistance)); + expect( + bounds.width, + inExclusiveRange(destination.width, releasedCover.width), + ); + previousDistance = distance; + } + await tester.pumpAndSettle(); + expect(player, findsNothing); + expect(tester.getRect(miniCover), destination); + expect(tester.takeException(), isNull); }, - ), - ); - mediaItems.add(item('first')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open')); - await tester.pump(); - - void expectAttached() { - final panel = tester.getRect(find.byType(MornyePlayerBackground)); - final artwork = tester.getRect(find.byType(MornyePlayerArtwork)); - expect(artwork.left, closeTo(panel.left, 0.01)); - expect(artwork.top, closeTo(panel.top, 0.01)); - expect(artwork.width, closeTo(393, 0.01)); - expect(artwork.height, closeTo(393, 0.01)); - } - - for (var frame = 0; frame < 8; frame++) { - await tester.pump(const Duration(milliseconds: 40)); - expectAttached(); - } - await tester.pumpAndSettle(); - expectAttached(); - Navigator.of(tester.element(find.byType(NowPlayingScreen))).pop(); - await tester.pump(); - for (var frame = 0; frame < 5; frame++) { - await tester.pump(const Duration(milliseconds: 40)); - expectAttached(); - expect( - tester.getTopLeft(find.byType(MornyePlayerBackground)).dy, - greaterThan(0), ); } - await tester.pumpAndSettle(); - expect(find.byType(NowPlayingScreen), findsNothing); - expect(tester.takeException(), isNull); - }); + } + + for (final lyrics in [true, false]) { + testWidgets( + 'motion cover moves into and expands out of ${lyrics ? 'lyrics' : 'queue'}', + (tester) async { + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + tester.view.viewPadding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + motionArtwork: const MotionArtwork( + 'file:///cover.mp4', + aspectRatio: 0.75, + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + final fullCover = find.descendant( + of: find.byType(MornyePlayerBackground), + matching: find.byType(MornyePlayerArtwork), + ); + final fullBounds = tester.getRect(fullCover); + final toggle = find.byIcon( + lyrics ? CupertinoIcons.quote_bubble : CupertinoIcons.list_bullet, + ); + final compact = find.byKey(const ValueKey('compact-player-artwork')); + final playback = find.widgetWithIcon( + MornyePlaybackButton, + CupertinoIcons.play_fill, + ); + await tester.tap(toggle); + await tester.pump(); + expect(tester.getRect(compact), fullBounds); + var previous = fullBounds; + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 80)); + final bounds = tester.getRect(compact); + expect(bounds.width, lessThan(previous.width)); + expect(bounds.center.dy, lessThan(previous.center.dy)); + expect(playback.hitTestable(), findsOneWidget); + previous = bounds; + } + await tester.pumpAndSettle(); + final compactBounds = tester.getRect(compact); + expect(compactBounds.size, const Size(72, 72)); + final header = find.byKey(const ValueKey('compact-track-header')); + expect(tester.getRect(header).left, compactBounds.right + 12); + expect(tester.getRect(header).center.dy, compactBounds.center.dy); + await tester.tap(toggle); + await tester.pump(); + expect(tester.getRect(compact), compactBounds); + previous = compactBounds; + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 80)); + final bounds = tester.getRect(compact); + expect(bounds.width, greaterThan(previous.width)); + expect(bounds.center.dy, greaterThan(previous.center.dy)); + expect(playback.hitTestable(), findsOneWidget); + previous = bounds; + } + await tester.pumpAndSettle(); + expect(compact, findsNothing); + expect(tester.getRect(fullCover), fullBounds); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'compact header finishes fading after closing ${lyrics ? 'lyrics' : 'queue'}', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + 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); + for (var visit = 0; visit < 2; visit++) { + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(opacity(), 1); + await tester.tap(toggle); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(opacity(), inExclusiveRange(0, 1)); + await tester.pumpAndSettle(); + expect(opacity(), 0); + expect( + find.byKey(const ValueKey('full-player-artwork')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + } + }, + ); + } testWidgets('Mornye lyrics replace artwork while controls stay in place', ( tester, @@ -1119,8 +1591,8 @@ void main() { expect(find.byType(PageView), findsNothing); expect(find.text('First lyric').hitTestable(), findsOneWidget); expect(tester.getSize(artwork).width, lessThan(fullArtwork.width)); - expect(fullArtwork.top, 0); - expect(fullArtwork.width, 393); + expect(fullArtwork.top, greaterThan(0)); + expect(fullArtwork.width, lessThan(393)); expect(tester.getRect(volume), volumeRect); expect(tester.getRect(play), playRect); @@ -1134,10 +1606,7 @@ void main() { await tester.pump(const Duration(milliseconds: 150)); expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0)); expect( - find.descendant( - of: find.byType(MornyePlayerBackground), - matching: find.byType(Hero), - ), + find.descendant(of: fullCover, matching: find.byType(Hero)), findsOneWidget, ); await tester.pumpAndSettle();