From 73051a5feb83884de323237ae21472841b7caa71 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 23 Aug 2026 14:26:07 +0700 Subject: [PATCH] fix(player): smooth timed lyrics highlighting --- lib/screens/now_playing_screen.dart | 525 +++++++++++++++++-- lib/utils/synced_lyrics_scroll.dart | 70 +++ test/now_playing_lyrics_transition_test.dart | 24 +- test/synced_lyrics_scroll_test.dart | 109 ++++ 4 files changed, 692 insertions(+), 36 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index bd8336f0..dcfc01d2 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1310,8 +1310,14 @@ class _SyncedLyricsView extends ConsumerStatefulWidget { class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final ScrollController _scroll = ScrollController(); ProviderSubscription? _positionSubscription; + ProviderSubscription? _playingSubscription; + ProviderSubscription? _loadingSubscription; + Timer? _lineBoundaryTimer; late List _lineKeys; int _active = -1; + Duration _activeTransitionPosition = Duration.zero; + bool _playing = false; + bool _loading = false; bool _userScrolling = false; static const double _estimatedLyricExtent = 64; @@ -1344,32 +1350,87 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { void _syncPositionSubscription() { _positionSubscription?.close(); + _playingSubscription?.close(); + _loadingSubscription?.close(); + _lineBoundaryTimer?.cancel(); _positionSubscription = null; + _playingSubscription = null; + _loadingSubscription = null; if (!widget.isActive) return; - _active = LyricsParser.activeIndex( - widget.lyrics.lines, - ref.read(playbackPositionProvider), - ); + final position = ref.read(playbackPositionProvider); + _playing = ref.read(playbackPlayingProvider); + _loading = ref.read(playbackLoadingProvider); + _active = LyricsParser.activeIndex(widget.lyrics.lines, position); + _activeTransitionPosition = position; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_maybeAutoScroll(_active)); }); + _scheduleNextLine(position); _positionSubscription = ref.listenManual( playbackPositionProvider, (previous, next) { final active = LyricsParser.activeIndex(widget.lyrics.lines, next); - if (active == _active || !mounted) return; - setState(() => _active = active); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_maybeAutoScroll(active)); - }); + if (active != _active) _setActiveLine(active, position: next); + _scheduleNextLine(next); }, ); + _playingSubscription = ref.listenManual(playbackPlayingProvider, ( + previous, + next, + ) { + _playing = next; + _scheduleNextLine(ref.read(playbackPositionProvider)); + }); + _loadingSubscription = ref.listenManual(playbackLoadingProvider, ( + previous, + next, + ) { + _loading = next; + _scheduleNextLine(ref.read(playbackPositionProvider)); + }); + } + + void _setActiveLine(int active, {required Duration position}) { + if (!mounted || active == _active) return; + setState(() { + _active = active; + _activeTransitionPosition = position; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_maybeAutoScroll(active)); + }); + } + + void _scheduleNextLine(Duration position) { + _lineBoundaryTimer?.cancel(); + if (!widget.isActive || !_playing || _loading) return; + + final lines = widget.lyrics.lines; + final dueIndex = syncedLyricsDueLineIndex( + lineStarts: lines.map((line) => line.time).toList(growable: false), + currentIndex: _active, + position: position, + ); + if (dueIndex != _active) { + _setActiveLine(dueIndex, position: position); + } + + final nextIndex = dueIndex + 1; + if (nextIndex >= lines.length) return; + final boundary = lines[nextIndex].time; + _lineBoundaryTimer = Timer(boundary - position, () { + if (!mounted || !widget.isActive || !_playing || _loading) return; + _scheduleNextLine(boundary); + }); } @override void dispose() { _positionSubscription?.close(); + _playingSubscription?.close(); + _loadingSubscription?.close(); + _lineBoundaryTimer?.cancel(); _scroll.dispose(); super.dispose(); } @@ -1462,6 +1523,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { content = _WordHighlightedLyricLine( line: line, colorScheme: widget.colorScheme, + animate: widget.isActive, + initialPosition: _activeTransitionPosition, ); } else { content = Text( @@ -1480,6 +1543,16 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ), ); } + content = AnimatedSwitcher( + duration: const Duration(milliseconds: 320), + reverseDuration: const Duration(milliseconds: 260), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: KeyedSubtree( + key: ValueKey(isActive && line.hasWordTiming), + child: content, + ), + ); return Padding( key: _lineKeys[index], @@ -1508,42 +1581,428 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } } -class _WordHighlightedLyricLine extends ConsumerWidget { +class _WordHighlightedLyricLine extends ConsumerStatefulWidget { final LyricLine line; final ColorScheme colorScheme; + final bool animate; + final Duration initialPosition; const _WordHighlightedLyricLine({ required this.line, required this.colorScheme, + required this.animate, + required this.initialPosition, }); @override - Widget build(BuildContext context, WidgetRef ref) { - final position = ref.watch(playbackPositionProvider); - final spans = []; - for (final word in line.words) { - final sung = position >= word.time; - spans.add( - TextSpan( - text: word.text, - style: TextStyle( - color: sung - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - ), - ); + ConsumerState<_WordHighlightedLyricLine> createState() => + _WordHighlightedLyricLineState(); +} + +class _WordHighlightedLyricLineState + extends ConsumerState<_WordHighlightedLyricLine> + with SingleTickerProviderStateMixin { + late final AnimationController _animationClock; + final Stopwatch _elapsedClock = Stopwatch(); + ProviderSubscription? _positionSubscription; + ProviderSubscription? _playingSubscription; + ProviderSubscription? _loadingSubscription; + + late Duration _anchorPosition; + Duration _anchorElapsed = Duration.zero; + bool _playing = false; + bool _loading = false; + + bool get _shouldAnimate => widget.animate && _playing && !_loading; + + Duration _positionAt({required bool advance}) { + return interpolatedSyncedLyricsPosition( + anchorPosition: _anchorPosition, + elapsedSinceAnchor: _elapsedClock.elapsed - _anchorElapsed, + isPlaying: advance, + ); + } + + @override + void initState() { + super.initState(); + _elapsedClock.start(); + _anchorElapsed = _elapsedClock.elapsed; + _anchorPosition = widget.initialPosition; + _playing = ref.read(playbackPlayingProvider); + _loading = ref.read(playbackLoadingProvider); + _animationClock = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + _positionSubscription = ref.listenManual( + playbackPositionProvider, + (previous, next) => _updateReportedPosition(next), + ); + _playingSubscription = ref.listenManual( + playbackPlayingProvider, + (previous, next) => _updateTransportState(playing: next), + ); + _loadingSubscription = ref.listenManual( + playbackLoadingProvider, + (previous, next) => _updateTransportState(loading: next), + ); + _syncAnimationClock(); + } + + @override + void didUpdateWidget(covariant _WordHighlightedLyricLine oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.line != widget.line || + oldWidget.initialPosition != widget.initialPosition) { + _anchorAt(widget.initialPosition); } - return RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - height: 1.4, - fontWeight: FontWeight.bold, - ), - children: spans, + if (oldWidget.animate != widget.animate) { + _anchorAt( + _positionAt(advance: oldWidget.animate && _playing && !_loading), + ); + _syncAnimationClock(); + } + } + + Duration _currentPosition() { + return _positionAt(advance: _shouldAnimate); + } + + void _anchorAt(Duration position) { + _anchorPosition = position; + _anchorElapsed = _elapsedClock.elapsed; + } + + void _updateReportedPosition(Duration position) { + if (!mounted) return; + final predicted = _currentPosition(); + _anchorAt( + reconcileSyncedLyricsPosition( + predictedPosition: predicted, + reportedPosition: position, ), ); + setState(() {}); + } + + void _updateTransportState({bool? playing, bool? loading}) { + if (!mounted) return; + final position = _currentPosition(); + if (playing != null) _playing = playing; + if (loading != null) _loading = loading; + _anchorAt(position); + _syncAnimationClock(); + setState(() {}); + } + + void _syncAnimationClock() { + if (_shouldAnimate) { + if (!_animationClock.isAnimating) { + _animationClock.repeat(); + } + } else { + _animationClock.stop(); + } + } + + Duration _segmentEnd(int index) { + final start = widget.line.words[index].time; + if (index + 1 < widget.line.words.length) { + final next = widget.line.words[index + 1].time; + if (next > start) return next; + } + final lineEnd = widget.line.end; + if (lineEnd != null && lineEnd > start) return lineEnd; + return start + const Duration(milliseconds: 650); + } + + @override + void dispose() { + _positionSubscription?.close(); + _playingSubscription?.close(); + _loadingSubscription?.close(); + _animationClock.dispose(); + _elapsedClock.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _buildHighlightedLine(context); + } + + Widget _buildHighlightedLine(BuildContext context) { + final highlightedColor = widget.colorScheme.onSurface; + final pendingColor = widget.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ); + final segments = []; + final starts = []; + final ends = []; + for (var index = 0; index < widget.line.words.length; index++) { + final word = widget.line.words[index]; + segments.add(word.text); + starts.add(word.time); + ends.add(_segmentEnd(index)); + } + + return _SweepingTimedLyricText( + segments: segments, + starts: starts, + ends: ends, + currentPosition: _currentPosition, + repaint: _animationClock, + style: (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) + .copyWith(height: 1.4, fontWeight: FontWeight.bold), + pendingColor: pendingColor, + highlightedColor: highlightedColor, + semanticsLabel: widget.line.text, + ); + } +} + +class _SweepingTimedLyricText extends StatefulWidget { + final List segments; + final List starts; + final List ends; + final Duration Function() currentPosition; + final Listenable repaint; + final TextStyle style; + final Color pendingColor; + final Color highlightedColor; + final String semanticsLabel; + + const _SweepingTimedLyricText({ + required this.segments, + required this.starts, + required this.ends, + required this.currentPosition, + required this.repaint, + required this.style, + required this.pendingColor, + required this.highlightedColor, + required this.semanticsLabel, + }); + + @override + State<_SweepingTimedLyricText> createState() => + _SweepingTimedLyricTextState(); +} + +class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { + TextPainter? _pendingPainter; + TextPainter? _highlightedPainter; + String? _cachedText; + TextStyle? _cachedStyle; + TextDirection? _cachedDirection; + TextScaler? _cachedScaler; + Locale? _cachedLocale; + Color? _cachedPendingColor; + Color? _cachedHighlightedColor; + + void _ensurePainters( + String text, + TextDirection textDirection, + TextScaler textScaler, + Locale? locale, + ) { + if (_cachedText == text && + _cachedStyle == widget.style && + _cachedDirection == textDirection && + _cachedScaler == textScaler && + _cachedLocale == locale && + _cachedPendingColor == widget.pendingColor && + _cachedHighlightedColor == widget.highlightedColor) { + return; + } + + _pendingPainter?.dispose(); + _highlightedPainter?.dispose(); + _pendingPainter = TextPainter( + text: TextSpan( + text: text, + style: widget.style.copyWith(color: widget.pendingColor), + ), + textAlign: TextAlign.center, + textDirection: textDirection, + textScaler: textScaler, + locale: locale, + ); + _highlightedPainter = TextPainter( + text: TextSpan( + text: text, + style: widget.style.copyWith(color: widget.highlightedColor), + ), + textAlign: TextAlign.center, + textDirection: textDirection, + textScaler: textScaler, + locale: locale, + ); + _cachedText = text; + _cachedStyle = widget.style; + _cachedDirection = textDirection; + _cachedScaler = textScaler; + _cachedLocale = locale; + _cachedPendingColor = widget.pendingColor; + _cachedHighlightedColor = widget.highlightedColor; + } + + @override + void dispose() { + _pendingPainter?.dispose(); + _highlightedPainter?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textDirection = Directionality.of(context); + final textScaler = MediaQuery.textScalerOf(context); + final locale = Localizations.maybeLocaleOf(context); + final text = widget.segments.join(); + _ensurePainters(text, textDirection, textScaler, locale); + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : double.infinity; + final pendingPainter = _pendingPainter! + ..layout( + minWidth: constraints.hasBoundedWidth ? maxWidth : 0, + maxWidth: maxWidth, + ); + final highlightedPainter = _highlightedPainter! + ..layout( + minWidth: constraints.hasBoundedWidth ? maxWidth : 0, + maxWidth: maxWidth, + ); + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : pendingPainter.width; + final height = pendingPainter.height; + + return Semantics( + label: widget.semanticsLabel, + child: CustomPaint( + size: Size(width, height), + painter: _TimedLyricSweepPainter( + segments: widget.segments, + starts: widget.starts, + ends: widget.ends, + currentPosition: widget.currentPosition, + repaint: widget.repaint, + pendingPainter: pendingPainter, + highlightedPainter: highlightedPainter, + ), + ), + ); + }, + ); + } +} + +class _TimedLyricSweepPainter extends CustomPainter { + final List segments; + final List starts; + final List ends; + final Duration Function() currentPosition; + final TextPainter pendingPainter; + final TextPainter highlightedPainter; + + _TimedLyricSweepPainter({ + required this.segments, + required this.starts, + required this.ends, + required this.currentPosition, + required Listenable repaint, + required this.pendingPainter, + required this.highlightedPainter, + }) : super(repaint: repaint); + + @override + void paint(Canvas canvas, Size size) { + pendingPainter.paint(canvas, Offset.zero); + + final completedPath = Path(); + final partialBoxes = <(Rect, double)>[]; + final position = currentPosition(); + var offset = 0; + for (var index = 0; index < segments.length; index++) { + final end = offset + segments[index].length; + final value = index < starts.length && index < ends.length + ? syncedLyricSegmentProgress( + position: position, + start: starts[index], + end: ends[index], + ) + : 0.0; + if (value > 0 && end > offset) { + final boxes = highlightedPainter.getBoxesForSelection( + TextSelection(baseOffset: offset, extentOffset: end), + ); + for (final box in boxes) { + final rect = box.toRect(); + if (value >= 1) { + completedPath.addRect(rect); + } else { + partialBoxes.add((rect, value)); + } + } + } + offset = end; + } + + if (!completedPath.getBounds().isEmpty) { + canvas.save(); + canvas.clipPath(completedPath); + highlightedPainter.paint(canvas, Offset.zero); + canvas.restore(); + } + + for (final (box, value) in partialBoxes) { + final boundary = syncedLyricsLeftToRightBoundary( + left: box.left, + right: box.right, + progress: value, + ); + final feather = (box.width * 0.18).clamp(3.0, 10.0); + final revealRight = (boundary + feather).clamp(box.left, box.right); + final revealRect = Rect.fromLTRB( + box.left, + box.top, + revealRight, + box.bottom, + ); + final gradientStart = boundary.clamp(box.left, revealRight - 0.01); + + canvas.save(); + canvas.clipRect(revealRect); + canvas.saveLayer(revealRect, Paint()); + highlightedPainter.paint(canvas, Offset.zero); + final mask = Paint() + ..blendMode = BlendMode.dstIn + ..shader = + LinearGradient( + colors: const [Colors.white, Colors.transparent], + ).createShader( + Rect.fromLTRB(gradientStart, box.top, revealRight, box.bottom), + ); + canvas.drawRect(revealRect, mask); + canvas.restore(); + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant _TimedLyricSweepPainter oldDelegate) { + return oldDelegate.segments != segments || + oldDelegate.starts != starts || + oldDelegate.ends != ends || + oldDelegate.currentPosition != currentPosition || + oldDelegate.pendingPainter != pendingPainter || + oldDelegate.highlightedPainter != highlightedPainter; } } diff --git a/lib/utils/synced_lyrics_scroll.dart b/lib/utils/synced_lyrics_scroll.dart index 95cf323c..852f9a50 100644 --- a/lib/utils/synced_lyrics_scroll.dart +++ b/lib/utils/synced_lyrics_scroll.dart @@ -21,3 +21,73 @@ double syncedLyricsEstimatedOffset({ if (index <= 0) return 0; return index * estimatedLineExtent; } + +/// Advances to the final lyric line whose timestamp is already due. +int syncedLyricsDueLineIndex({ + required List lineStarts, + required int currentIndex, + required Duration position, +}) { + if (lineStarts.isEmpty) return -1; + var dueIndex = currentIndex; + if (dueIndex < -1) dueIndex = -1; + if (dueIndex >= lineStarts.length) dueIndex = lineStarts.length - 1; + while (dueIndex + 1 < lineStarts.length && + lineStarts[dueIndex + 1] <= position) { + dueIndex++; + } + return dueIndex; +} + +/// Extrapolates the latest player position for the lyrics animation only. +/// +/// The global playback state intentionally updates at a lower frequency to +/// avoid rebuilding unrelated UI on every frame. The active lyric line uses +/// this value to animate between those updates. +Duration interpolatedSyncedLyricsPosition({ + required Duration anchorPosition, + required Duration elapsedSinceAnchor, + required bool isPlaying, +}) { + if (!isPlaying || elapsedSinceAnchor.isNegative) return anchorPosition; + return anchorPosition + elapsedSinceAnchor; +} + +/// Keeps small timing corrections from making the lyric highlight jump while +/// still applying a seek or another large position change immediately. +Duration reconcileSyncedLyricsPosition({ + required Duration predictedPosition, + required Duration reportedPosition, + Duration seekThreshold = const Duration(milliseconds: 750), +}) { + final drift = (reportedPosition - predictedPosition).abs(); + if (drift >= seekThreshold) return reportedPosition; + + const correctionFraction = 0.25; + final correction = + ((reportedPosition - predictedPosition).inMicroseconds * + correctionFraction) + .round(); + return predictedPosition + Duration(microseconds: correction); +} + +/// Continuous progress for one timed TTML or enhanced LRC segment. +double syncedLyricSegmentProgress({ + required Duration position, + required Duration start, + required Duration end, +}) { + if (position <= start) return 0; + if (position >= end || end <= start) return 1; + return (position - start).inMicroseconds / (end - start).inMicroseconds; +} + +/// Horizontal leading edge for a highlight that fills left to right. +double syncedLyricsLeftToRightBoundary({ + required double left, + required double right, + required double progress, +}) { + final value = progress.clamp(0.0, 1.0); + return left + ((right - left) * value); +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index b45c90c9..56a1d096 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -24,11 +24,14 @@ void main() { } final arguments = (call.arguments as Map).cast(); final path = arguments['file_path']?.toString() ?? ''; + final lyrics = path.endsWith('/timed.flac') + ? '''

Short

''' + : path.endsWith('/second.flac') + ? '[00:01.00]Second lyric' + : '[00:01.00]First lyric'; return jsonEncode({ 'title': path.endsWith('/second.flac') ? 'Second' : 'First', - 'lyrics': path.endsWith('/second.flac') - ? '[00:01.00]Second lyric' - : '[00:01.00]First lyric', + 'lyrics': lyrics, }); }); }); @@ -89,6 +92,21 @@ void main() { }, ); + testWidgets('short timed lyric remains horizontally centered', ( + tester, + ) async { + await pumpNowPlaying(tester); + + mediaItems.add(item('timed')); + await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(-700, 0)); + await tester.pumpAndSettle(); + + final lyric = find.bySemanticsLabel('Short'); + expect(lyric, findsOneWidget); + expect(tester.getCenter(lyric).dx, closeTo(540, 1)); + }); + testWidgets('Now Playing menu exposes Go to Album when album is known', ( tester, ) async { diff --git a/test/synced_lyrics_scroll_test.dart b/test/synced_lyrics_scroll_test.dart index 939aa797..4fa3aa08 100644 --- a/test/synced_lyrics_scroll_test.dart +++ b/test/synced_lyrics_scroll_test.dart @@ -27,4 +27,113 @@ void main() { 24, ); }); + + test('advances at the exact next line boundary', () { + const starts = [ + Duration(seconds: 1), + Duration(seconds: 3), + Duration(seconds: 3), + Duration(seconds: 6), + ]; + + expect( + syncedLyricsDueLineIndex( + lineStarts: starts, + currentIndex: 0, + position: const Duration(milliseconds: 2999), + ), + 0, + ); + expect( + syncedLyricsDueLineIndex( + lineStarts: starts, + currentIndex: 0, + position: const Duration(seconds: 3), + ), + 2, + ); + }); + + group('smooth timed lyric highlight', () { + test('interpolates position only while playback is advancing', () { + expect( + interpolatedSyncedLyricsPosition( + anchorPosition: const Duration(seconds: 10), + elapsedSinceAnchor: const Duration(milliseconds: 250), + isPlaying: true, + ), + const Duration(milliseconds: 10250), + ); + expect( + interpolatedSyncedLyricsPosition( + anchorPosition: const Duration(seconds: 10), + elapsedSinceAnchor: const Duration(milliseconds: 250), + isPlaying: false, + ), + const Duration(seconds: 10), + ); + }); + + test('blends small clock corrections and applies seeks immediately', () { + expect( + reconcileSyncedLyricsPosition( + predictedPosition: const Duration(milliseconds: 1000), + reportedPosition: const Duration(milliseconds: 1100), + ), + const Duration(milliseconds: 1025), + ); + expect( + reconcileSyncedLyricsPosition( + predictedPosition: const Duration(seconds: 1), + reportedPosition: const Duration(seconds: 5), + ), + const Duration(seconds: 5), + ); + }); + + test('calculates continuous progress inside a timed segment', () { + const start = Duration(seconds: 2); + const end = Duration(seconds: 3); + + expect( + syncedLyricSegmentProgress( + position: const Duration(milliseconds: 1500), + start: start, + end: end, + ), + 0, + ); + expect( + syncedLyricSegmentProgress( + position: const Duration(milliseconds: 2250), + start: start, + end: end, + ), + 0.25, + ); + expect( + syncedLyricSegmentProgress( + position: const Duration(milliseconds: 3500), + start: start, + end: end, + ), + 1, + ); + }); + + test('moves the reveal boundary from left to right', () { + expect( + syncedLyricsLeftToRightBoundary(left: 10, right: 110, progress: 0), + 10, + ); + expect( + syncedLyricsLeftToRightBoundary(left: 10, right: 110, progress: 0.5), + 60, + ); + expect( + syncedLyricsLeftToRightBoundary(left: 10, right: 110, progress: 1), + 110, + ); + }); + }); }