mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(lyrics): group smaller backing vocals beneath their lead
This commit is contained in:
1 parent
443a9a2bdf
commit
fedf5f137a
6 files changed
+346
-39
No files matched your search
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user