fix(lyrics): hold current line highlight until the next lead

This commit is contained in:
zarzet committed 2026-10-06 14:20:54 +07:00
1 parent 4b5e40c167
commit 4be1073203
4 files changed
+232 -31

No files matched your search

+99
View File
@@ -80,6 +80,8 @@ class _DonatePageState extends State<DonatePage> {
],
_DonateLinksCard(colorScheme: colorScheme, config: _config),
const SizedBox(height: 16),
_RecentSupportersCard(supporters: _config.supporters),
const SizedBox(height: 16),
_DonateNoticeCard(
colorScheme: colorScheme,
notices: _config.notices,
@@ -309,6 +311,103 @@ class _DonateLinksCard extends StatelessWidget {
}
}
class _RecentSupportersCard extends StatelessWidget {
const _RecentSupportersCard({required this.supporters});
final List<String> supporters;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Card(
elevation: 0,
color: Color.alphaBlend(
(isDark ? Colors.white : Colors.black).withValues(
alpha: isDark ? 0.08 : 0.04,
),
colorScheme.surface,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.star_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Recent Supporters',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'Thank you for your generosity!',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
if (supporters.isEmpty)
Text(
'No recent supporters to show.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final name in supporters)
Chip(
backgroundColor: colorScheme.secondaryContainer,
side: BorderSide.none,
shape: const StadiumBorder(),
avatar: CircleAvatar(
backgroundColor: colorScheme.primary.withValues(
alpha: 0.2,
),
child: Text(
name.isEmpty
? '?'
: name.characters.first.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
label: Text(
name,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
);
}
}
class _DonateNoticeCard extends StatelessWidget {
final ColorScheme colorScheme;
final List<String> notices;
+11 -4
View File
@@ -58,28 +58,35 @@ class LyricDisplayLayout {
}
}
/// Keep overlapping vocal parts lit until their explicit end. Untimed lines
/// retain the normal single-line behavior instead of guessing a vocal length.
/// Hold the latest lead until the next lead starts, including instrumental
/// gaps. Earlier overlapping parts and backing vocals keep their timed ends.
Set<int> activeLyricIndices(
List<LyricLine> lines,
Duration position,
int currentIndex,
) {
final active = <int>{};
var heldLead = -1;
for (var i = 0; i <= currentIndex; i++) {
final line = lines[i];
if (!line.isBackground &&
line.text.isNotEmpty &&
(heldLead < 0 || line.time != lines[heldLead].time)) {
// Simultaneous singers retain their shared first-row focus.
heldLead = i;
}
final timedVoice =
(i < currentIndex || line.voice != null || line.isBackground) &&
line.text.isNotEmpty &&
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.
// Secondary simultaneous singers and backing parts keep their own ends.
final simultaneous =
line.text.isNotEmpty && line.time == lines[currentIndex].time;
if (timedVoice ? line.end! > position : i == currentIndex || simultaneous) {
active.add(i);
}
}
if (heldLead >= 0) active.add(heldLead);
return active;
}
+60 -17
View File
@@ -26,7 +26,7 @@ void main() {
});
test(
'simultaneous timed singers retain individual ends and shared focus',
'simultaneous timed singers hold the primary and respect reply ends',
() {
final lines = LyricsParser.parse('''
[00:01.00]v1:<00:01.00>Lead<00:05.00>
@@ -36,7 +36,7 @@ void main() {
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);
expect(activeLyricIndices(lines, const Duration(seconds: 5), 1), {0});
},
);
@@ -107,16 +107,43 @@ void main() {
},
);
test(
'overlapping singers stay active until their ends, including seek back',
() {
final lines = LyricsParser.parse('''
test('overlapping singers retain earlier ends and hold the current lead', () {
final lines = LyricsParser.parse('''
[00:01.00]v1:<00:01.00>Lead<00:05.00>
[00:02.00]v2:<00:02.00>Guest<00:04.00>
[00:06.00]v3:Third
''').lines;
Set<int> active(int seconds) {
final position = Duration(seconds: seconds);
Set<int> active(int seconds) {
final position = Duration(seconds: seconds);
return activeLyricIndices(
lines,
position,
LyricsParser.activeIndex(lines, position),
);
}
expect(active(0), isEmpty);
expect(active(1), {0});
expect(active(3), {0, 1});
expect(active(4), {0, 1});
expect(active(5), {1});
expect(active(6), {2});
expect(active(3), {0, 1});
expect(active(1), {0});
});
for (final timed in [false, true]) {
test('holds the last vocal through a countdown and seeks ($timed)', () {
final lines = lyricsTimelineWithGaps(
LyricsParser.parse(
timed
? '[00:01.00]v1:<00:01.00>Held<00:02.00>\n'
'[00:07.00]v2:<00:07.00>Next<00:08.00>'
: '[00:01.00]Held\n[00:02.00]\n[00:07.00]Next',
).lines,
);
Set<int> active(int milliseconds) {
final position = Duration(milliseconds: milliseconds);
return activeLyricIndices(
lines,
position,
@@ -124,16 +151,32 @@ void main() {
);
}
expect(lines.map((line) => line.text), ['Held', '', 'Next']);
expect(active(0), isEmpty);
expect(active(1), {0});
expect(active(3), {0, 1});
expect(active(4), {0});
expect(active(5), isEmpty);
expect(active(6), {2});
expect(active(3), {0, 1});
expect(active(1), {0});
},
);
expect(active(1000), {0});
expect(active(2000), {0, 1});
expect(active(6999), {0, 1});
expect(active(7000), {2});
expect(active(8000), {2});
expect(active(6999), {0, 1});
expect(active(1000), {0});
expect(active(0), isEmpty);
});
}
test('an ended backing part cannot replace the held lead', () {
final lines = lyricsTimelineWithGaps(
LyricsParser.parse('''
[00:01.00]v1:<00:01.00>Lead<00:02.00>
[bg:<00:03.00>Echo<00:04.00>]
[00:07.00]v2:<00:07.00>Next<00:08.00>
''').lines,
);
expect(lines.map((line) => line.text), ['Lead', 'Echo', '', 'Next']);
expect(activeLyricIndices(lines, const Duration(seconds: 3), 1), {0, 1});
expect(activeLyricIndices(lines, const Duration(seconds: 5), 2), {0, 2});
expect(activeLyricIndices(lines, const Duration(seconds: 7), 3), {3});
});
List<(int, int)> gaps(String text) =>
lyricsTimelineWithGaps(LyricsParser.parse(text).lines)
+62 -10
View File
@@ -3248,7 +3248,7 @@ void main() {
lessThan(tester.getSize(timed('Lead')).height * 0.8),
);
await seek(6500);
expect(timed('Lead'), findsNothing);
expect(timed('Lead'), findsOneWidget);
expect(timed('Echo'), findsOneWidget);
expect(offset(), closeTo(leadOffset, 0.1));
await seek(7500);
@@ -3261,7 +3261,7 @@ void main() {
if (mornye) {
expect(
tester.getTopLeft(find.text('Echo')).dy -
tester.getBottomLeft(find.text('Lead')).dy,
tester.getBottomLeft(timed('Lead')).dy,
closeTo(6, 0.1),
);
}
@@ -3367,6 +3367,12 @@ void main() {
expect(find.textContaining('v1:'), findsNothing);
expect(find.textContaining('v2:'), findsNothing);
Finder timed(String text) => find.descendant(
of: find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
@@ -3376,13 +3382,7 @@ void main() {
);
await tester.pumpAndSettle();
for (final text in ['Lead', 'Guest']) {
final paint = find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
final paint = timed(text);
expect(
paint,
findsOneWidget,
@@ -3433,7 +3433,7 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(find.text('Guest'), findsOneWidget);
expect(timed('Guest'), findsOneWidget);
expect(find.text('Lead'), findsNothing);
playback.add(
PlaybackState(
@@ -3444,6 +3444,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Lead'), findsOneWidget);
expect(timed('Guest'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
@@ -3579,6 +3580,57 @@ void main() {
},
);
testWidgets('finished lyric stays bright until the next lead ($mornye)', (
tester,
) async {
metadataOverrides['lyrics'] =
'[00:01.00]v1:<00:01.00>Held<00:02.00>\n'
'[00:07.00]v2:<00:07.00>Next<00:08.00>';
final playback = StreamController<PlaybackState>.broadcast();
addTearDown(playback.close);
await pumpNowPlaying(
tester,
theme: mornye ? MornyeTheme.build(Brightness.dark) : null,
size: const Size(390, 844),
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();
Finder timed(String text) => find.descendant(
of: find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
for (final milliseconds in [1500, 2500, 6999, 7000, 8000, 6999]) {
playback.add(
PlaybackState(
playing: false,
processingState: AudioProcessingState.ready,
updatePosition: Duration(milliseconds: milliseconds),
),
);
await tester.pumpAndSettle();
final text = milliseconds < 7000 ? 'Held' : 'Next';
expect(timed(text), findsOneWidget);
final opacity = tester.widget<AnimatedOpacity>(
find
.ancestor(of: timed(text), matching: find.byType(AnimatedOpacity))
.first,
);
expect(opacity.opacity, 1, reason: 'At $milliseconds ms');
expect(timed(text == 'Held' ? 'Next' : 'Held'), findsNothing);
}
expect(tester.takeException(), isNull);
});
testWidgets(
'original and romanization follow word timing and pauses ($mornye)',
(tester) async {