diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 9642d4bd..f0053b25 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -4,8 +4,8 @@ import 'dart:ui' show BoxHeightStyle, ImageFilter; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoIcons; -import 'package:flutter/foundation.dart' show ValueListenable; -import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:flutter/foundation.dart' show ValueListenable, listEquals; +import 'package:flutter/rendering.dart' show OverflowBoxFit, ScrollDirection; import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; @@ -2916,7 +2916,8 @@ class _SyncedLyricsView extends ConsumerStatefulWidget { ConsumerState<_SyncedLyricsView> createState() => _SyncedLyricsViewState(); } -class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { +class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> + with SingleTickerProviderStateMixin { final ScrollController _scroll = ScrollController(); ProviderSubscription? _positionSubscription; ProviderSubscription? _playingSubscription; @@ -2940,6 +2941,49 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { Object? _lineLayoutKey; double? _viewportHeight; Offset? _layoutVisibility; + late final AnimationController _rowReveal = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + )..addListener(_revealTick); + List _revealFrom = []; + List _revealTo = []; + List? _layoutRowVisibility; + List? _targetLineExtents; + bool _preserveRevealAnchor = false; + + void _revealTick() { + if (mounted) setState(() {}); + } + + List get _rowVisibility { + final progress = Curves.easeInOutCubic.transform(_rowReveal.value); + return List.generate( + _lines.length, + (index) => + _revealFrom[index] + + (_revealTo[index] - _revealFrom[index]) * progress, + ); + } + + void _updateRowVisibility({bool immediate = false, bool sameFocus = false}) { + _preserveRevealAnchor = sameFocus; + final target = List.generate(_lines.length, (index) { + final line = _lines[index]; + if (line.text.isEmpty) return _activeLines.contains(index) ? 1.0 : 0.0; + if (line.isBackground) return index <= _active ? 1.0 : 0.0; + return 1.0; + }); + if (immediate || MediaQuery.disableAnimationsOf(context)) { + _rowReveal.stop(); + _revealFrom = target; + _revealTo = target; + return; + } + if (listEquals(target, _revealTo)) return; + _revealFrom = _rowVisibility; + _revealTo = target; + _rowReveal.forward(from: 0); + } // The header stays fixed when controls collapse. Anchor lyrics to it, not // to a fraction of the growing viewport, including during the transition. @@ -2990,6 +3034,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _lineExtents = null; _lineLayoutKey = null; _lines = lyricsTimelineWithGaps(widget.lyrics.lines); + _rowReveal.stop(); + _revealFrom = _revealTo = [ + for (final line in _lines) + if (line.isBackground || line.text.isEmpty) 0.0 else 1.0, + ]; + _layoutRowVisibility = null; + _targetLineExtents = null; _displayLayout = LyricDisplayLayout(_lines); _lineKeys = List.generate( _lines.length, @@ -3015,6 +3066,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _loading = ref.read(playbackLoadingProvider); _active = _activeIndexAt(position); _activeLines = activeLyricIndices(_lines, position, _active); + _updateRowVisibility(immediate: true); _activeTransitionPosition = position; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true)); @@ -3102,6 +3154,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _active = active; _activeLines = activeLines; _activeTransitionPosition = position; + _updateRowVisibility( + sameFocus: + active >= 0 && _displayLayout.focusForLine[active] == previousFocus, + ); }); if (!indexChanged || (active >= 0 && _displayLayout.focusForLine[active] == previousFocus)) { @@ -3161,6 +3217,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _lineBoundaryTimer?.cancel(); _userScrollIdleTimer?.cancel(); _scroll.dispose(); + _rowReveal.dispose(); super.dispose(); } @@ -3247,7 +3304,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final duration = immediate || MediaQuery.disableAnimationsOf(context) ? Duration.zero : Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380); - final extents = _lineExtents; + final extents = _targetLineExtents; if (context.isMornye && extents != null && row < extents.length) { final position = _scroll.position; final target = extents.take(row).fold(0.0, (sum, extent) => sum + extent); @@ -3336,6 +3393,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { Widget _buildLyrics(BuildContext context, Offset visibility) { final lines = _lines; final active = _active; + final rowVisibility = _rowVisibility; final loading = ref.watch(playbackLoadingProvider); final mornye = context.isMornye; final highContrast = MediaQuery.highContrastOf(context); @@ -3383,7 +3441,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _measureMornyeLines(layoutWidth); // Reuse text measurements; only interpolate row heights as the // supplements fade. Scrolling follows the same animation clock. - _lineExtents = List.generate(_lineMeasurements.length, (row) { + final fullExtents = List.generate(_lineMeasurements.length, (row) { final (primary, pronunciation, translation) = _lineMeasurements[row]; final index = _displayLayout.lineOrder[row]; @@ -3396,9 +3454,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { translation * visibility.dy) * lyricScale; }); + _lineExtents = List.generate(fullExtents.length, (row) { + return fullExtents[row] * + rowVisibility[_displayLayout.lineOrder[row]]; + }); + _targetLineExtents = List.generate(fullExtents.length, (row) { + return fullExtents[row] * + _revealTo[_displayLayout.lineOrder[row]]; + }); if (previousExtents != null && - _layoutVisibility != null && - _layoutVisibility != visibility && + ((_layoutVisibility != null && + _layoutVisibility != visibility) || + (_preserveRevealAnchor && + _layoutRowVisibility != null && + !listEquals(_layoutRowVisibility, rowVisibility))) && previousExtents.length == _lineExtents!.length && _scroll.hasClients) { var anchor = _active < 0 @@ -3423,6 +3492,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } } _layoutVisibility = visibility; + _layoutRowVisibility = rowVisibility; if (_viewportHeight != constraints.maxHeight) { _viewportHeight = constraints.maxHeight; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -3465,6 +3535,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final line = lines[index]; final leadIndex = _displayLayout.leadForLine[index]; final textAlign = _lyricTextAlign(context, lines[leadIndex]); + final reveal = rowVisibility[index]; + if (reveal == 0) return SizedBox.shrink(key: _lineKeys[index]); final isActive = _activeLines.contains(index); final isPast = index < active; @@ -3477,33 +3549,37 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8); if (line.text.isEmpty) { - return Padding( - key: _lineKeys[index], - padding: EdgeInsets.symmetric( - vertical: context.tokens.lyricsLinePaddingV, - ), - child: SizedBox( - height: 24, - child: Align( - alignment: mornye - ? Alignment.centerLeft - : Alignment.center, - child: - isActive && - widget.isActive && - (!loading || widget.seekPreview.value != null) - ? ValueListenableBuilder( - valueListenable: widget.seekPreview, - builder: (context, preview, _) => - LyricGapIndicator( - key: ValueKey(line.time), - start: line.time, - end: line.end!, - color: color, - position: preview, - ), - ) - : null, + return _revealRow( + index, + reveal, + textAlign, + Padding( + padding: EdgeInsets.symmetric( + vertical: context.tokens.lyricsLinePaddingV, + ), + child: SizedBox( + height: 24, + child: Align( + alignment: mornye + ? Alignment.centerLeft + : Alignment.center, + child: + isActive && + widget.isActive && + (!loading || widget.seekPreview.value != null) + ? ValueListenableBuilder( + valueListenable: widget.seekPreview, + builder: (context, preview, _) => + LyricGapIndicator( + key: ValueKey(line.time), + start: line.time, + end: line.end!, + color: color, + position: preview, + ), + ) + : null, + ), ), ), ); @@ -3606,23 +3682,27 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ); } - return Padding( - key: _lineKeys[index], - padding: _linePadding(index), - child: GestureDetector( - onTap: () => - ref.read(musicPlayerControllerProvider).seek(line.time), - child: AnimatedScale( - scale: mornye || isActive ? 1.0 : 0.96, - alignment: Alignment.center, - duration: const Duration(milliseconds: 280), - curve: Curves.easeOutCubic, - child: AnimatedOpacity( - opacity: mornye - ? (isActive || highContrast ? 1 : 0.48) - : (isActive ? 1.0 : (isPast ? 0.55 : 0.85)), + return _revealRow( + index, + reveal, + textAlign, + Padding( + padding: _linePadding(index), + child: GestureDetector( + onTap: () => + ref.read(musicPlayerControllerProvider).seek(line.time), + child: AnimatedScale( + scale: mornye || isActive ? 1.0 : 0.96, + alignment: Alignment.center, duration: const Duration(milliseconds: 280), - child: content, + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: mornye + ? (isActive || highContrast ? 1 : 0.48) + : (isActive ? 1.0 : (isPast ? 0.55 : 0.85)), + duration: const Duration(milliseconds: 280), + child: content, + ), ), ), ), @@ -3634,6 +3714,39 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ); } + Widget _revealRow( + int index, + double visibility, + TextAlign align, + Widget child, + ) { + return KeyedSubtree( + key: _lineKeys[index], + child: !_lines[index].isBackground && _lines[index].text.isNotEmpty + ? child + : IgnorePointer( + ignoring: visibility < 1, + child: LyricSupplementTransition( + visibility: visibility, + alignment: align == TextAlign.right + ? Alignment.topRight + : align == TextAlign.center + ? Alignment.topCenter + : Alignment.topLeft, + // A fixed-extent row is already shrinking. Measure its + // contents at full height so fitting does not scale it twice. + child: OverflowBox( + minHeight: 0, + maxHeight: double.infinity, + fit: OverflowBoxFit.deferToChild, + alignment: Alignment.topCenter, + child: child, + ), + ), + ), + ); + } + EdgeInsets _linePadding(int index) { final row = _displayLayout.rowForLine[index]; final order = _displayLayout.lineOrder; diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 862c3f24..771b3382 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -1384,6 +1384,32 @@ void main() { return (dot.decoration! as BoxDecoration).color!.a; }); + if (layout != 'material') { + // Before playback, the countdown must reserve no blank row. Its + // entrance pushes the upcoming vocal down instead of jumping it. + expect(find.byType(LyricGapIndicator), findsNothing); + final firstVocal = find.text('First vocal'); + final before = tester.getTopLeft(firstVocal).dy; + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 3), + ), + ); + await tester.pump(); + await tester.pump(); + var previous = before; + for (var frame = 0; frame < 4; frame++) { + await tester.pump(const Duration(milliseconds: 80)); + final top = tester.getTopLeft(firstVocal).dy; + expect(top, greaterThan(previous)); + expect(top - before, lessThan(56)); + previous = top; + } + await tester.pumpAndSettle(); + expect(tester.getTopLeft(firstVocal).dy - before, closeTo(56, 0.1)); + } + for (final (seconds, expected) in [ (3, [1.0, 0.25, 0.25]), (6, [1.0, 1.0, 0.25]), @@ -2758,19 +2784,7 @@ void main() { } await tester.pumpAndSettle(); final lead = tester.widget(find.text('Lead')); - final backing = tester.widget(find.text('Echo')); - expect( - backing.style!.fontSize, - lessThan(lead.style!.fontSize! * 0.85), - ); - expect(backing.textAlign, TextAlign.right); - if (mornye) { - expect( - tester.getTopLeft(find.text('Echo')).dy - - tester.getBottomLeft(find.text('Lead')).dy, - closeTo(6, 0.1), - ); - } + expect(find.text('Echo'), findsNothing); expect(find.textContaining('[bg:'), findsNothing); expect(find.textContaining('v1:'), findsNothing); @@ -2798,7 +2812,25 @@ void main() { await seek(2000); expect(timed('Lead'), findsOneWidget); expect(timed('Echo'), findsNothing); + expect(find.text('Echo'), findsNothing); final leadOffset = offset(); + final leadTop = tester.getTopLeft(timed('Lead')).dy; + var nextTop = tester.getTopLeft(find.text('Next')).dy; + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 3), + ), + ); + await tester.pump(); + await tester.pump(); + for (var frame = 0; frame < 4; frame++) { + await tester.pump(const Duration(milliseconds: 80)); + expect(tester.getTopLeft(timed('Lead')).dy, closeTo(leadTop, 0.1)); + final top = tester.getTopLeft(find.text('Next')).dy; + expect(top, greaterThan(nextTop)); + nextTop = top; + } await seek(4000); expect(timed('Lead'), findsOneWidget); expect(timed('Echo'), findsOneWidget); @@ -2811,9 +2843,24 @@ void main() { expect(timed('Lead'), findsNothing); expect(timed('Echo'), findsOneWidget); expect(offset(), closeTo(leadOffset, 0.1)); + await seek(7500); + final backing = tester.widget(find.text('Echo')); + expect( + backing.style!.fontSize, + lessThan(lead.style!.fontSize! * 0.85), + ); + expect(backing.textAlign, TextAlign.right); + if (mornye) { + expect( + tester.getTopLeft(find.text('Echo')).dy - + tester.getBottomLeft(find.text('Lead')).dy, + closeTo(6, 0.1), + ); + } await seek(2000); expect(timed('Lead'), findsOneWidget); expect(timed('Echo'), findsNothing); + expect(find.text('Echo'), findsNothing); expect(tester.takeException(), isNull); }, );