fix(lyrics): keep simultaneous singers highlighted and visible

This commit is contained in:
zarzet committed 2026-10-04 03:30:02 +07:00
1 parent 3191ff926f
commit dad84eb342
3 files changed
+104 -3

No files matched your search

+13 -3
View File
@@ -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<int> 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;
}
+37
View File
@@ -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>
@@ -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<PlaybackState>.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<AnimatedOpacity>(
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 {