mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
fix(lyrics): hold current line highlight until the next lead
This commit is contained in:
1 parent
4b5e40c167
commit
4be1073203
4 files changed
+232
-31
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user