diff --git a/lib/utils/lyrics_timeline.dart b/lib/utils/lyrics_timeline.dart index b6ab7885..9a51e1a4 100644 --- a/lib/utils/lyrics_timeline.dart +++ b/lib/utils/lyrics_timeline.dart @@ -42,10 +42,16 @@ class LyricDisplayLayout { rowForLine[lineOrder[row]] = row; } var latestLead = 0; + Duration? latestLeadStart; for (var i = 0; i < lines.length; i++) { - if (!lines[i].isBackground) latestLead = i; + if (!lines[i].isBackground && lines[i].time != latestLeadStart) { + latestLead = i; + latestLeadStart = lines[i].time; + } + // Simultaneous leads share the first row as their scroll anchor, so the + // earlier singer cannot be pushed above the viewport while still singing. // A delayed echo must not scroll backwards after the next lead starts. - focusForLine[i] = leadForLine[i] > latestLead + focusForLine[i] = lines[i].isBackground && leadForLine[i] > latestLead ? leadForLine[i] : latestLead; } @@ -68,7 +74,11 @@ Set activeLyricIndices( line.end != null; // Known ends also apply to the most recently started singer, so a short // reply does not stay lit over a longer lead vocal. - if (timedVoice ? line.end! > position : i == currentIndex) active.add(i); + final simultaneous = + line.text.isNotEmpty && line.time == lines[currentIndex].time; + if (timedVoice ? line.end! > position : i == currentIndex || simultaneous) { + active.add(i); + } } return active; } diff --git a/test/lyrics_timeline_test.dart b/test/lyrics_timeline_test.dart index 22fa8f58..1c9d0a9f 100644 --- a/test/lyrics_timeline_test.dart +++ b/test/lyrics_timeline_test.dart @@ -3,6 +3,43 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/lyrics_timeline.dart'; void main() { + test('simultaneous untimed singers share highlighting and scroll focus', () { + final lines = LyricsParser.parse(''' +[00:01.00]v1:Lead +[00:01.00]v2:Guest +[00:05.00]Next +''').lines; + final layout = LyricDisplayLayout(lines); + expect(layout.focusForLine, [0, 0, 2]); + for (final seconds in [1, 2, 4]) { + final position = Duration(seconds: seconds); + expect( + activeLyricIndices( + lines, + position, + LyricsParser.activeIndex(lines, position), + ), + {0, 1}, + ); + } + expect(activeLyricIndices(lines, const Duration(seconds: 5), 2), {2}); + }); + + test( + 'simultaneous timed singers retain individual ends and shared focus', + () { + final lines = LyricsParser.parse(''' +[00:01.00]v1:<00:01.00>Lead<00:05.00> +[00:01.00]v2:<00:01.00>Guest<00:03.00> +[00:06.00]Next +''').lines; + expect(LyricDisplayLayout(lines).focusForLine, [0, 0, 2]); + expect(activeLyricIndices(lines, const Duration(seconds: 2), 1), {0, 1}); + expect(activeLyricIndices(lines, const Duration(seconds: 4), 1), {0}); + expect(activeLyricIndices(lines, const Duration(seconds: 5), 1), isEmpty); + }, + ); + test('backing stays under its lead without changing the timing order', () { final lines = LyricsParser.parse(''' [00:01.00]v1:<00:01.00>Lead<00:06.00> diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index edc2139b..ae29809f 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -3273,6 +3273,60 @@ void main() { ); } + testWidgets( + 'simultaneous wrapped lyrics stay inside the viewport ($mornye)', + (tester) async { + const lead = + 'The first singer has a long line that wraps across several rows'; + const guest = 'The second singer starts at exactly the same timestamp'; + metadataOverrides['lyrics'] = + '[00:01.00]v1:$lead\n[00:01.00]v2:$guest\n[00:08.00]Next'; + final playback = StreamController.broadcast(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: mornye ? MornyeTheme.build(Brightness.dark) : null, + size: const Size(390, 1100), + playbackEvents: playback.stream, + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap( + mornye + ? find.byIcon(CupertinoIcons.quote_bubble) + : find.byKey(const ValueKey('material-lyrics-toggle')), + ); + await tester.pumpAndSettle(); + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pumpAndSettle(); + final scrollable = find + .ancestor(of: find.text(lead), matching: find.byType(Scrollable)) + .first; + final viewport = tester.getRect(scrollable); + for (final text in [lead, guest]) { + final bounds = tester.getRect(find.text(text)); + expect(bounds.top, greaterThanOrEqualTo(viewport.top)); + expect(bounds.bottom, lessThanOrEqualTo(viewport.bottom)); + final opacity = tester.widget( + find + .ancestor( + of: find.text(text), + matching: find.byType(AnimatedOpacity), + ) + .first, + ); + expect(opacity.opacity, 1); + } + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'singers keep their side before and during overlapping vocals ($mornye)', (tester) async {