mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
feat(lyrics): group smaller backing vocals beneath their lead
This commit is contained in:
@@ -33,6 +33,9 @@ class LyricLine {
|
||||
final LyricVoice? voice;
|
||||
final bool isBackground;
|
||||
|
||||
/// Links backing vocals to their lead even when another singer overlaps.
|
||||
final int? vocalGroup;
|
||||
|
||||
const LyricLine({
|
||||
required this.time,
|
||||
this.end,
|
||||
@@ -43,6 +46,7 @@ class LyricLine {
|
||||
this.translation,
|
||||
this.voice,
|
||||
this.isBackground = false,
|
||||
this.vocalGroup,
|
||||
});
|
||||
|
||||
bool get hasWordTiming => words.isNotEmpty;
|
||||
@@ -286,6 +290,7 @@ class LyricsParser {
|
||||
words: words,
|
||||
voice: voice ?? previous.voice,
|
||||
isBackground: true,
|
||||
vocalGroup: previous.vocalGroup,
|
||||
),
|
||||
);
|
||||
sawWordTiming |= words.isNotEmpty;
|
||||
@@ -315,8 +320,12 @@ class LyricsParser {
|
||||
end: words.lastOrNull?.end,
|
||||
text: cleanContent,
|
||||
words: words,
|
||||
voice: voice,
|
||||
voice:
|
||||
voice ?? (background != null ? parsed.lastOrNull?.voice : null),
|
||||
isBackground: background != null,
|
||||
vocalGroup: background != null
|
||||
? parsed.lastOrNull?.vocalGroup
|
||||
: parsed.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -362,6 +371,7 @@ class LyricsParser {
|
||||
text: l.text,
|
||||
voice: l.voice,
|
||||
isBackground: l.isBackground,
|
||||
vocalGroup: l.vocalGroup,
|
||||
romanization: romanization,
|
||||
romanizationWords: _shiftWords(validWords, offsetMs),
|
||||
translation: alignedTranslation[l.time.inMilliseconds],
|
||||
@@ -535,6 +545,7 @@ class LyricsParser {
|
||||
final lines = <LyricLine>[];
|
||||
final plain = <String>[];
|
||||
var sawWords = false;
|
||||
var nextVocalGroup = 0;
|
||||
|
||||
for (final p in paragraphs) {
|
||||
final begin = _parseClock(p.getAttribute('begin'));
|
||||
@@ -593,6 +604,10 @@ class LyricsParser {
|
||||
}
|
||||
|
||||
visit(p, inheritedVoice, false, null, null);
|
||||
final leadGroups = {
|
||||
for (final run in runs)
|
||||
if (!run.background && run.text.isNotEmpty) run: nextVocalGroup++,
|
||||
};
|
||||
for (final run in runs) {
|
||||
final lineText = run.text;
|
||||
if (lineText.isEmpty) continue;
|
||||
@@ -603,6 +618,12 @@ class LyricsParser {
|
||||
: words.firstOrNull?.time ?? begin;
|
||||
if (lineBegin == null) continue;
|
||||
sawWords |= words.isNotEmpty;
|
||||
final lead = run.background
|
||||
? leadGroups.keys
|
||||
.where((lead) => lead.voice?.id == run.voice?.id)
|
||||
.firstOrNull ??
|
||||
leadGroups.keys.firstOrNull
|
||||
: run;
|
||||
lines.add(
|
||||
LyricLine(
|
||||
time: lineBegin,
|
||||
@@ -611,6 +632,9 @@ class LyricsParser {
|
||||
words: words,
|
||||
voice: run.voice,
|
||||
isBackground: run.background,
|
||||
vocalGroup:
|
||||
leadGroups[lead] ??
|
||||
(run.background ? lines.lastOrNull?.vocalGroup : null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
|
||||
/// Visual order is separate from the chronological timeline: a backing part
|
||||
/// stays directly under its lead, including when another singer starts first.
|
||||
class LyricDisplayLayout {
|
||||
final List<int> lineOrder;
|
||||
final List<int> rowForLine;
|
||||
final List<int> leadForLine;
|
||||
final List<int> focusForLine;
|
||||
|
||||
LyricDisplayLayout(List<LyricLine> lines)
|
||||
: lineOrder = [],
|
||||
rowForLine = List.filled(lines.length, 0),
|
||||
leadForLine = List.generate(lines.length, (index) => index),
|
||||
focusForLine = List.filled(lines.length, 0) {
|
||||
final leads = {
|
||||
for (var i = 0; i < lines.length; i++)
|
||||
if (!lines[i].isBackground && lines[i].vocalGroup != null)
|
||||
lines[i].vocalGroup!: i,
|
||||
};
|
||||
final backing = <int, List<int>>{};
|
||||
int? previousLead;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
if (!line.isBackground) {
|
||||
if (line.text.isNotEmpty) previousLead = i;
|
||||
continue;
|
||||
}
|
||||
final lead = line.vocalGroup == null
|
||||
? previousLead
|
||||
: leads[line.vocalGroup];
|
||||
if (lead == null) continue;
|
||||
leadForLine[i] = lead;
|
||||
backing.putIfAbsent(lead, () => []).add(i);
|
||||
}
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (leadForLine[i] != i) continue;
|
||||
lineOrder.add(i);
|
||||
lineOrder.addAll(backing[i] ?? const []);
|
||||
}
|
||||
for (var row = 0; row < lineOrder.length; row++) {
|
||||
rowForLine[lineOrder[row]] = row;
|
||||
}
|
||||
var latestLead = 0;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].isBackground) latestLead = i;
|
||||
// A delayed echo must not scroll backwards after the next lead starts.
|
||||
focusForLine[i] = leadForLine[i] > latestLead
|
||||
? leadForLine[i]
|
||||
: latestLead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep overlapping vocal parts lit until their explicit end. Untimed lines
|
||||
/// retain the normal single-line behavior instead of guessing a vocal length.
|
||||
Set<int> activeLyricIndices(
|
||||
|
||||
Reference in New Issue
Block a user