feat(lyrics): group smaller backing vocals beneath their lead

This commit is contained in:
zarzet committed 2026-09-27 04:01:43 +07:00
1 parent 443a9a2bdf
commit fedf5f137a
6 files changed
+346 -39

No files matched your search

+91 -38
View File
@@ -2903,6 +2903,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
Timer? _lineBoundaryTimer;
Timer? _userScrollIdleTimer;
late List<LyricLine> _lines;
late LyricDisplayLayout _displayLayout;
late List<GlobalKey> _lineKeys;
int _active = -1;
Set<int> _activeLines = {};
@@ -2968,6 +2969,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_lineExtents = null;
_lineLayoutKey = null;
_lines = lyricsTimelineWithGaps(widget.lyrics.lines);
_displayLayout = LyricDisplayLayout(_lines);
_lineKeys = List<GlobalKey>.generate(
_lines.length,
(index) => GlobalKey(debugLabel: 'lyric-line-$index'),
@@ -3067,6 +3069,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
if (!mounted) return;
final activeLines = activeLyricIndices(_lines, position, active);
final indexChanged = active != _active;
final previousFocus = _active < 0
? -1
: _displayLayout.focusForLine[_active];
if (!indexChanged &&
activeLines.length == _activeLines.length &&
activeLines.containsAll(_activeLines)) {
@@ -3077,7 +3082,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_activeLines = activeLines;
_activeTransitionPosition = position;
});
if (!indexChanged) return;
if (!indexChanged ||
(active >= 0 && _displayLayout.focusForLine[active] == previousFocus)) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && active == _active) unawaited(_maybeAutoScroll(active));
});
@@ -3136,11 +3144,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
}
void _measureMornyeLines(double width) {
final style = Theme.of(context).textTheme.headlineSmall?.copyWith(
height: 1.3,
fontSize: _mornyeLyricFontSize,
fontWeight: FontWeight.bold,
);
final style = _mornyeLyricStyle(context);
final scaler = MediaQuery.textScalerOf(context);
final direction = Directionality.of(context);
final locale = Localizations.maybeLocaleOf(context);
@@ -3153,16 +3157,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
locale: locale,
);
final measurements = <(double, double, double)>[];
final layouts = <LyricPronunciationLayout?>[];
for (final line in _lines) {
final layouts = List<LyricPronunciationLayout?>.filled(_lines.length, null);
for (final index in _displayLayout.lineOrder) {
final line = _lines[index];
if (line.text.isEmpty) {
measurements.add((56, 0, 0));
layouts.add(null);
continue;
}
painter.text = TextSpan(text: line.text, style: style);
painter.text = TextSpan(
text: line.text,
style: _mornyeLyricStyle(context, background: line.isBackground),
);
painter.layout(maxWidth: width);
var height = painter.height + 32;
final padding = _linePadding(index).vertical;
var height = painter.height + padding;
var pronunciationHeight = 0.0;
var translationHeight = 0.0;
LyricPronunciationLayout? pronunciationLayout;
@@ -3178,7 +3186,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
pronunciationHeight = 6 + painter.height;
final aligned = LyricPronunciationLayout.measure(
line: line,
primaryStyle: _mornyeLyricStyle(context),
primaryStyle: _mornyeLyricStyle(
context,
background: line.isBackground,
),
pronunciationStyle: style,
maxWidth: width,
textScaler: scaler,
@@ -3187,13 +3198,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
);
if (aligned != null) {
pronunciationLayout = aligned;
height = aligned.primaryHeight + 32;
height = aligned.primaryHeight + padding;
pronunciationHeight = aligned.pronunciationHeight;
}
}
}
measurements.add((height, pronunciationHeight, translationHeight));
layouts.add(pronunciationLayout);
layouts[index] = pronunciationLayout;
}
_lineMeasurements = measurements;
_pronunciationLayouts = layouts;
@@ -3204,16 +3215,21 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
// A short intro may have no countdown row. Still return to the first
// upcoming lyric when the user scrubs back before any vocals.
if (index < 0 && widget.seekPreview.value != null) index = 0;
if (_userScrolling || index < 0 || !_scroll.hasClients) return;
if (_userScrolling ||
index < 0 ||
index >= _lines.length ||
!_scroll.hasClients) {
return;
}
index = _displayLayout.focusForLine[index];
final row = _displayLayout.rowForLine[index];
final duration = immediate || MediaQuery.disableAnimationsOf(context)
? Duration.zero
: Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380);
final extents = _lineExtents;
if (context.isMornye && extents != null && index < extents.length) {
if (context.isMornye && extents != null && row < extents.length) {
final position = _scroll.position;
final target = extents
.take(index)
.fold(0.0, (sum, extent) => sum + extent);
final target = extents.take(row).fold(0.0, (sum, extent) => sum + extent);
final offset = target.clamp(
position.minScrollExtent,
position.maxScrollExtent,
@@ -3245,7 +3261,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
final position = _scroll.position;
final target = syncedLyricsEstimatedOffset(
index: index,
index: row,
estimatedLineExtent: _estimatedLyricExtent,
);
final clamped = target.clamp(
@@ -3263,7 +3279,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
}
if (!mounted ||
_userScrolling ||
index != _active ||
(_active >= 0 && index != _displayLayout.focusForLine[_active]) ||
index >= _lineKeys.length) {
return;
}
@@ -3345,7 +3361,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_layoutVisibility != visibility &&
previousExtents.length == _lineExtents!.length &&
_scroll.hasClients) {
var anchor = _active.clamp(0, previousExtents.length);
var anchor = _active < 0
? 0
: _displayLayout.rowForLine[_displayLayout
.focusForLine[_active]];
if (_userScrolling) {
anchor = 0;
var extent = 0.0;
@@ -3398,10 +3417,12 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
: null,
padding: EdgeInsets.fromLTRB(24, topPadding, 24, bottomPadding),
itemCount: lines.length + (widget.credits == null ? 0 : 1),
itemBuilder: (context, index) {
if (index == lines.length) return widget.credits!;
itemBuilder: (context, row) {
if (row == lines.length) return widget.credits!;
final index = _displayLayout.lineOrder[row];
final line = lines[index];
final textAlign = _lyricTextAlign(context, line);
final leadIndex = _displayLayout.leadForLine[index];
final textAlign = _lyricTextAlign(context, lines[leadIndex]);
final isActive = _activeLines.contains(index);
final isPast = index < active;
@@ -3473,7 +3494,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
: Theme.of(context).textTheme.titleLarge)
?.copyWith(
height: context.tokens.lyricsLineHeight,
fontSize: mornye ? _mornyeLyricFontSize : null,
fontSize: line.isBackground
? (mornye ? _mornyeLyricFontSize * 0.68 : 18)
: mornye
? _mornyeLyricFontSize
: null,
fontWeight: mornye || isActive
? FontWeight.bold
: FontWeight.w500,
@@ -3503,10 +3528,17 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
),
);
if (mornye) {
final distance = (index - active).abs();
final activeLead = active < 0
? -1
: _displayLayout.focusForLine[active];
final distance = activeLead < 0
? row + 1
: (_displayLayout.rowForLine[leadIndex] -
_displayLayout.rowForLine[activeLead])
.abs();
// Nearby lines need visible defocus at the larger lyric size;
// progressively soften lines further from the current one.
final sigma = !blurLyrics || isActive
final sigma = !blurLyrics || isActive || leadIndex == activeLead
? 0.0
: active < 0
? 4.8
@@ -3529,9 +3561,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
return Padding(
key: _lineKeys[index],
padding: EdgeInsets.symmetric(
vertical: context.tokens.lyricsLinePaddingV,
),
padding: _linePadding(index),
child: GestureDetector(
onTap: () =>
ref.read(musicPlayerControllerProvider).seek(line.time),
@@ -3556,6 +3586,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
),
);
}
EdgeInsets _linePadding(int index) {
final row = _displayLayout.rowForLine[index];
final order = _displayLayout.lineOrder;
final hasBackingBelow =
row + 1 < order.length &&
_lines[order[row + 1]].isBackground &&
_displayLayout.leadForLine[order[row + 1]] ==
_displayLayout.leadForLine[index];
return EdgeInsets.only(
top: _lines[index].isBackground ? 6 : context.tokens.lyricsLinePaddingV,
bottom: hasBackingBelow ? 0 : context.tokens.lyricsLinePaddingV,
);
}
}
class _LyricsCredits extends StatelessWidget {
@@ -3608,9 +3652,11 @@ Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
yield (
text,
base.copyWith(
fontSize: context.isMornye
? (translation ? 18 : 22)
: (translation ? 14 : 16),
fontSize:
(context.isMornye
? (translation ? 18.0 : 22.0)
: (translation ? 14.0 : 16.0)) *
(line.isBackground ? 0.8 : 1),
height: 1.35,
fontWeight: context.isMornye
? (translation ? FontWeight.w600 : FontWeight.bold)
@@ -3622,9 +3668,9 @@ Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
}
}
TextStyle _mornyeLyricStyle(BuildContext context) =>
TextStyle _mornyeLyricStyle(BuildContext context, {bool background = false}) =>
(Theme.of(context).textTheme.headlineSmall ?? const TextStyle()).copyWith(
fontSize: _mornyeLyricFontSize,
fontSize: _mornyeLyricFontSize * (background ? 0.68 : 1),
height: context.tokens.lyricsLineHeight,
fontWeight: FontWeight.bold,
);
@@ -3686,7 +3732,10 @@ Widget _withLyricSupplements(
line.romanization?.trim().isNotEmpty != true) {
return withSupplements(primary);
}
final primaryStyle = _mornyeLyricStyle(context);
final primaryStyle = _mornyeLyricStyle(
context,
background: line.isBackground,
);
final pronunciationStyle = supplements
.firstWhere((supplement) => !supplement.$4)
.$2;
@@ -3900,7 +3949,11 @@ class _WordHighlightedLyricLineState
final style =
(Theme.of(context).textTheme.headlineSmall ?? const TextStyle())
.copyWith(
fontSize: context.isMornye ? _mornyeLyricFontSize : null,
fontSize: widget.line.isBackground
? (context.isMornye ? _mornyeLyricFontSize * 0.68 : 18)
: context.isMornye
? _mornyeLyricFontSize
: null,
height: context.tokens.lyricsLineHeight,
fontWeight: FontWeight.bold,
);
+25 -1
View File
@@ -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),
),
);
}
+52
View File
@@ -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(
+30
View File
@@ -47,8 +47,36 @@ ${_tag('translation', 2000, 'Second translation')}
expect(backing.isBackground, isTrue);
expect(backing.time.inMilliseconds, 2000);
expect(backing.end?.inMilliseconds, 4000);
expect(backing.vocalGroup, lyrics.lines.first.vocalGroup);
});
test(
'backing groups survive sorting, offsets and repeated backing parts',
() {
final lyrics = LyricsParser.parse('''
[offset:100]
[00:01.00]v2:<00:01.00>Lead<00:06.00>
[bg:<00:04.00>Echo<00:05.00>]
[bg:[00:05.00]<00:05.00>Again<00:06.00>]
[00:03.00]v1:<00:03.00>Other lead<00:06.00>
''');
expect(lyrics.lines.map((line) => line.text), [
'Lead',
'Other lead',
'Echo',
'Again',
]);
final lead = lyrics.lines.first;
expect(lyrics.lines[1].vocalGroup, isNot(lead.vocalGroup));
for (final backing in lyrics.lines.skip(2)) {
expect(backing.vocalGroup, lead.vocalGroup);
expect(backing.voice?.id, 'v2');
expect(backing.isBackground, isTrue);
}
expect(lyrics.lines[2].time.inMilliseconds, 3900);
},
);
test('TTML resolves inherited voices and explicit groups by namespace', () {
final lyrics = LyricsParser.parse('''
<t:tt xmlns:t="http://www.w3.org/ns/ttml" xmlns:m="http://www.w3.org/ns/ttml#metadata">
@@ -94,6 +122,8 @@ ${_tag('translation', 2000, 'Second translation')}
expect(lyrics.lines.map((line) => line.end?.inSeconds), [3, 4, 3]);
expect(lyrics.lines.last.isBackground, isTrue);
expect(lyrics.lines.last.voice?.id, 'v1');
expect(lyrics.lines.last.vocalGroup, lyrics.lines.first.vocalGroup);
expect(lyrics.lines[1].vocalGroup, isNot(lyrics.lines.first.vocalGroup));
},
);
+55
View File
@@ -3,6 +3,61 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/lyrics_timeline.dart';
void main() {
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>
[bg:<00:04.00>Echo<00:06.00>]
[bg:<00:05.00>Again<00:06.00>]
[00:03.00]v2:<00:03.00>Guest<00:06.00>
''').lines;
final layout = LyricDisplayLayout(lines);
expect(lines.map((line) => line.text), ['Lead', 'Guest', 'Echo', 'Again']);
expect(layout.lineOrder.map((i) => lines[i].text), [
'Lead',
'Echo',
'Again',
'Guest',
]);
expect(layout.rowForLine, [0, 3, 1, 2]);
expect(layout.leadForLine, [0, 1, 0, 0]);
expect(layout.focusForLine, [0, 1, 1, 1]);
expect(activeLyricIndices(lines, const Duration(seconds: 5), 3), {
0,
1,
2,
3,
});
});
test('background onset keeps focus on the lead and gaps remain separate', () {
final lines = lyricsTimelineWithGaps(
LyricsParser.parse('''
[00:05.00]v2:<00:05.00>Lead<00:10.00>
[bg:<00:06.00>Echo<00:11.00>]
[00:15.00]Next
''').lines,
);
final layout = LyricDisplayLayout(lines);
expect(lines.map((line) => line.text), ['', 'Lead', 'Echo', '', 'Next']);
expect(layout.lineOrder, [0, 1, 2, 3, 4]);
expect(layout.focusForLine, [0, 1, 1, 3, 4]);
expect(lines[3].time, const Duration(seconds: 11));
});
test(
'a backing pickup can start before the lead without losing its parent',
() {
final lines = LyricsParser.parse('''
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:m="http://www.w3.org/ns/ttml#metadata">
<body><p begin="1s" end="6s" m:agent="v2"><span m:role="x-bg" begin="1s" end="5s">Pickup</span><span begin="2s" end="6s">Lead</span></p></body></tt>
''').lines;
final layout = LyricDisplayLayout(lines);
expect(lines.map((line) => line.text), ['Pickup', 'Lead']);
expect(layout.lineOrder, [1, 0]);
expect(layout.focusForLine, [1, 1]);
},
);
test(
'backing vocals do not dim an unlabelled lead that is still singing',
() {
@@ -2593,6 +2593,99 @@ void main() {
}
for (final mornye in [false, true]) {
for (final ttml in [false, true]) {
testWidgets(
'backing vocals stay smaller under the lead with independent timing (Mornye: $mornye, TTML: $ttml)',
(tester) async {
metadataOverrides['lyrics'] = ttml
? '''
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:m="http://www.w3.org/ns/ttml#metadata">
<head><metadata><m:agent xml:id="v1" type="person"/><m:agent xml:id="v2" type="person"/></metadata></head>
<body><p begin="1s" end="7s" m:agent="v2"><span begin="1s" end="6s">Lead</span><span m:role="x-bg" m:agent="v1" begin="3s" end="7s">Echo</span></p><p begin="8s" end="10s">Next</p></body></tt>
'''
: '''
[00:01.00]v2:<00:01.00>Lead<00:06.00>
[bg:v1:<00:03.00>Echo<00:07.00>]
[00:08.00]<00:08.00>Next<00:10.00>
''';
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();
if (mornye) {
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
} else {
await tester.drag(find.byType(PageView), const Offset(-350, 0));
}
await tester.pumpAndSettle();
final lead = tester.widget<Text>(find.text('Lead'));
final backing = tester.widget<Text>(find.text('Echo'));
expect(
backing.style!.fontSize,
lessThan(lead.style!.fontSize! * 0.85),
);
expect(backing.textAlign, TextAlign.right);
if (mornye) {
expect(
tester.getTopLeft(find.text('Echo')).dy -
tester.getBottomLeft(find.text('Lead')).dy,
closeTo(6, 0.1),
);
}
expect(find.textContaining('[bg:'), findsNothing);
expect(find.textContaining('v1:'), findsNothing);
Finder timed(String text) => find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
Future<void> seek(int milliseconds) async {
playback.add(
PlaybackState(
playing: false,
processingState: AudioProcessingState.ready,
updatePosition: Duration(milliseconds: milliseconds),
),
);
await tester.pumpAndSettle();
}
double offset() =>
tester.widget<ListView>(find.byType(ListView)).controller!.offset;
await seek(2000);
expect(timed('Lead'), findsOneWidget);
expect(timed('Echo'), findsNothing);
final leadOffset = offset();
await seek(4000);
expect(timed('Lead'), findsOneWidget);
expect(timed('Echo'), findsOneWidget);
expect(offset(), closeTo(leadOffset, 0.1));
expect(
tester.getSize(timed('Echo')).height,
lessThan(tester.getSize(timed('Lead')).height * 0.8),
);
await seek(6500);
expect(timed('Lead'), findsNothing);
expect(timed('Echo'), findsOneWidget);
expect(offset(), closeTo(leadOffset, 0.1));
await seek(2000);
expect(timed('Lead'), findsOneWidget);
expect(timed('Echo'), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
testWidgets(
'singers keep their side before and during overlapping vocals ($mornye)',
(tester) async {