diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 04da7330..cabe42d2 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1181,38 +1181,57 @@ class _NowPlayingScreenState extends ConsumerState { _scheduleLyricsControlsHide(); } + Duration get _lyricsControlsMotion => MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220); + 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, - ), - ), + // Fade in place: resizing controls also resizes the lyric viewport and can + // interrupt its active drag or trigger a playback auto-scroll correction. + return IgnorePointer( + ignoring: hidden, + child: ExcludeSemantics( + excluding: hidden, + child: AnimatedOpacity( + opacity: hidden ? 0 : 1, + duration: _lyricsControlsMotion, + curve: Curves.easeOutCubic, + child: child, ), ), ); - return motion == Duration.zero - ? content - : AnimatedSize( - duration: motion, - curve: Curves.easeInOutCubic, - alignment: Alignment.topCenter, - child: content, - ); } + Widget _mornyeLyricsViewport(Widget child, {required double visibleHeight}) => + TweenAnimationBuilder( + tween: Tween(begin: 0, end: _lyricsControlsHidden ? 1 : 0), + duration: _lyricsControlsMotion, + curve: Curves.easeOutCubic, + builder: (context, reveal, child) => ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) { + final height = bounds.height.clamp(1.0, double.infinity); + final end = visibleHeight.clamp(0.0, height); + final start = (end - 24).clamp(0.0, end); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, start / height, end / height, 1], + colors: [ + Colors.white, + Colors.white, + Colors.white.withValues(alpha: reveal), + Colors.white.withValues(alpha: reveal), + ], + ).createShader(bounds); + }, + child: child, + ), + child: child, + ); + Widget _mornyePlayerPage( MediaItem mediaItem, MusicPlayerController controller, @@ -1370,9 +1389,15 @@ class _NowPlayingScreenState extends ConsumerState { ? MornyePlayerQueue( colorScheme: colorScheme, ) - : _lyricsSection( - colorScheme, - isActive: showLyrics, + : _mornyeLyricsViewport( + _lyricsSection( + colorScheme, + isActive: showLyrics, + showOptions: false, + ), + visibleHeight: + stage.minHeight - + (8 + compactHeaderHeight + 16), ), ) : null, @@ -1558,68 +1583,70 @@ class _NowPlayingScreenState extends ConsumerState { ); } - Widget content() => Column( - children: [ - 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: _mornyeContentInset + compactCoverSize + 12, + final lyricsOptions = showLyrics + ? _lyricsOptionsButton(colorScheme) + : null; + Widget content() => TweenAnimationBuilder( + tween: Tween(begin: 0, end: compactStage ? 1 : 0), + duration: motion, + curve: Curves.easeInOutCubic, + builder: (context, progress, controls) => CustomMultiChildLayout( + delegate: _PlayerHeaderLayout( + progress: progress, + compactHeight: compactHeaderHeight, + compactLeft: _mornyeContentInset + compactCoverSize + 12, + expandLyrics: showLyrics, + ), + 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, + ), ), - 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( - mainAxisSize: MainAxisSize.min, - children: [ - controls(), - SizedBox(height: volumeGap - transportShift), - AnimatedBuilder( - animation: _artworkColorsChanged, - builder: (context, _) => MornyeVolumeControl( - key: _artworkVolumeKey, - foreground: foreground('volume').onSurface, - ), + LayoutId(id: _PlayerHeaderSlot.controls, child: controls!), + if (lyricsOptions != null) + LayoutId( + id: _PlayerHeaderSlot.lyricsOptions, + child: _autoHidingLyricsControls(lyricsOptions), + ), + ], + ), + child: _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), - ], - ), + ), + const SizedBox(height: 8), + ], ), - ], + ), ); // Controls keep their intrinsic height. Small portrait screens and // large accessibility text can scroll instead of clipping the volume. - final minHeight = - (showLyrics && _lyricsControlsHidden ? 250.0 : 480.0) + - (scale - 1).clamp(0, 3) * 120; + final minHeight = 480.0 + (scale - 1).clamp(0, 3) * 120; if (constraints.maxHeight < minHeight) { return SingleChildScrollView( child: SizedBox(height: minHeight, child: content()), @@ -1820,7 +1847,11 @@ class _NowPlayingScreenState extends ConsumerState { ]; } - Widget _lyricsSection(ColorScheme colorScheme, {required bool isActive}) { + Widget _lyricsSection( + ColorScheme colorScheme, { + required bool isActive, + bool showOptions = true, + }) { if (_loadingMeta) { return const Center(child: AppLoadingIndicator()); } @@ -1854,7 +1885,7 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ); - final options = isActive && !_landscape + final options = showOptions && isActive && !_landscape ? _lyricsOptionsButton(colorScheme) : null; return Stack( @@ -2675,7 +2706,7 @@ class _NowPlayingScreenState extends ConsumerState { } } -enum _PlayerHeaderSlot { stage, header } +enum _PlayerHeaderSlot { stage, header, controls, lyricsOptions } /// Keep one live header throughout the cover/lyrics/queue transition. Measuring /// it at its current width also accommodates accessibility text sizes without @@ -2685,14 +2716,25 @@ class _PlayerHeaderLayout extends MultiChildLayoutDelegate { required this.progress, required this.compactHeight, required this.compactLeft, + required this.expandLyrics, }); final double progress; final double compactHeight; final double compactLeft; + final bool expandLyrics; @override void performLayout(Size size) { + final controls = layoutChild( + _PlayerHeaderSlot.controls, + BoxConstraints.tightFor(width: size.width), + ); + final contentHeight = (size.height - controls.height).clamp( + 0.0, + size.height, + ); + positionChild(_PlayerHeaderSlot.controls, Offset(0, contentHeight)); final left = 28 + (compactLeft - 28) * progress; // Align the visible ellipsis with the timeline while leaving its touch // target wider than the glyph and interpolating the same live header. @@ -2708,27 +2750,47 @@ class _PlayerHeaderLayout extends MultiChildLayoutDelegate { minHeight: compactHeight * progress, ), ); - final stageHeight = (size.height - (header.height + 20) * (1 - progress)) - .clamp(0.0, size.height); + final stageHeight = (contentHeight - (header.height + 20) * (1 - progress)) + .clamp(0.0, contentHeight); layoutChild( _PlayerHeaderSlot.stage, - BoxConstraints.tight(Size(size.width, stageHeight)), + // Lyrics always extend behind controls. The minimum height marks their + // unobscured area for the paint mask; hiding controls never changes either + // constraint, scroll padding or the Scrollable's identity. + BoxConstraints( + minWidth: size.width, + maxWidth: size.width, + minHeight: stageHeight, + maxHeight: + stageHeight + (expandLyrics ? controls.height * progress : 0), + ), ); positionChild(_PlayerHeaderSlot.stage, Offset.zero); positionChild( _PlayerHeaderSlot.header, Offset( left, - (size.height - header.height - 8) * (1 - progress) + 8 * progress, + (contentHeight - header.height - 8) * (1 - progress) + 8 * progress, ), ); + if (hasChild(_PlayerHeaderSlot.lyricsOptions)) { + final options = layoutChild( + _PlayerHeaderSlot.lyricsOptions, + BoxConstraints.loose(size), + ); + positionChild( + _PlayerHeaderSlot.lyricsOptions, + Offset(_mornyeContentInset, contentHeight - options.height - 8), + ); + } } @override bool shouldRelayout(_PlayerHeaderLayout oldDelegate) => progress != oldDelegate.progress || compactHeight != oldDelegate.compactHeight || - compactLeft != oldDelegate.compactLeft; + compactLeft != oldDelegate.compactLeft || + expandLyrics != oldDelegate.expandLyrics; } class _PlaybackControls extends ConsumerWidget { diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 480d4e4c..5df2eedd 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -816,50 +816,83 @@ void main() { expect(bar.title, isNull); }); - testWidgets('manual lyric scrolling hides controls down and restores them up', ( - tester, - ) async { - metadataOverrides = { - 'lyrics': List.generate( - 20, - (index) => - '[00:${(index * 3).toString().padLeft(2, '0')}.00]Lyric $index with several words on this line', - ).join('\n'), - }; - await pumpNowPlaying( - tester, - theme: MornyeTheme.build(Brightness.dark), - size: const Size(393, 780), - playback: PlaybackState(updatePosition: const Duration(seconds: 30)), + for (final height in [780.0, 520.0]) { + testWidgets( + 'manual lyric scrolling keeps its viewport stable while controls animate (height: $height)', + (tester) async { + metadataOverrides['lyrics'] = List.generate( + 20, + (index) => + '[00:${(index * 3).toString().padLeft(2, '0')}.00]Lyric $index with several words on this line', + ).join('\n'); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: Size(393, height), + playback: PlaybackState(updatePosition: const Duration(seconds: 30)), + ); + mediaItems.add(item('many')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + final list = find.byType(ListView); + final bounds = tester.getRect(list); + final scrollable = find.descendant( + of: list, + matching: find.byType(Scrollable), + ); + final state = tester.state(scrollable); + final initialOffset = state.position.pixels; + final headerTop = tester.getTopLeft(find.text('Second')).dy; + final transport = find.byWidgetPredicate( + (widget) => + widget is MornyePlaybackButton && + const [ + CupertinoIcons.play_fill, + CupertinoIcons.backward_fill, + CupertinoIcons.forward_fill, + ].contains(widget.icon), + ); + expect(transport.hitTestable(), findsNWidgets(3)); + final touch = await tester.startGesture( + Offset(bounds.center.dx, bounds.top + 100), + ); + await touch.moveBy(const Offset(0, -20)); + await tester.pump(); + await touch.moveBy(const Offset(0, -60)); + await tester.pump(); + final hiddenOffset = state.position.pixels; + expect(hiddenOffset, greaterThan(initialOffset)); + expect(transport.hitTestable(), findsNothing); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getRect(list), bounds); + expect(tester.state(scrollable), same(state)); + expect(state.position.pixels, closeTo(hiddenOffset, 0.01)); + } + + // Continue the same drag, then reverse before the hide completes. + await touch.moveBy(const Offset(0, -32)); + await tester.pump(); + expect(state.position.pixels, closeTo(hiddenOffset + 32, 0.01)); + await touch.moveBy(const Offset(0, 48)); + await tester.pump(); + final restoredOffset = state.position.pixels; + expect(restoredOffset, closeTo(hiddenOffset - 16, 0.01)); + for (var frame = 0; frame < 10; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getRect(list), bounds); + expect(tester.state(scrollable), same(state)); + expect(state.position.pixels, closeTo(restoredOffset, 0.01)); + } + expect(tester.getTopLeft(find.text('Second')).dy, headerTop); + expect(transport.hitTestable(), findsNWidgets(3)); + await touch.up(); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, ); - mediaItems.add(item('many')); - await tester.pumpAndSettle(); - await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); - final list = find.byType(ListView); - final initialHeight = tester.getSize(list).height; - final headerTop = tester.getTopLeft(find.text('Second')).dy; - final transport = find.byWidgetPredicate( - (widget) => - widget is MornyePlaybackButton && - const [ - CupertinoIcons.play_fill, - CupertinoIcons.backward_fill, - CupertinoIcons.forward_fill, - ].contains(widget.icon), - ); - expect(transport.hitTestable(), findsNWidgets(3)); - await tester.drag(list, const Offset(0, -140)); - await tester.pumpAndSettle(); - expect(tester.getSize(list).height, greaterThan(initialHeight + 100)); - expect(tester.getTopLeft(find.text('Second')).dy, closeTo(headerTop, 1)); - expect(transport.hitTestable(), findsNothing); - await tester.drag(list, const Offset(0, 140)); - await tester.pumpAndSettle(); - expect(tester.getSize(list).height, closeTo(initialHeight, 1)); - expect(transport.hitTestable(), findsNWidgets(3)); - expect(tester.takeException(), isNull); - }); + } for (final reducedMotion in [false, true]) { testWidgets( @@ -910,7 +943,7 @@ void main() { expect(volume.hitTestable(), findsNothing); expect(lyricsButton.hitTestable(), findsNothing); expect(queueButton.hitTestable(), findsNothing); - expect(tester.getSize(list).height, greaterThan(originalHeight + 150)); + expect(tester.getSize(list).height, originalHeight); expect( find.byKey(const ValueKey('player-track-header')).hitTestable(), findsOneWidget,