mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
fix(lyrics): keep simultaneous singers highlighted and visible
This commit is contained in:
1 parent
3191ff926f
commit
dad84eb342
3 files changed
+104
-3
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user