fix(lyrics): pulse instrumental countdown dots

This commit is contained in:
zarzet
2026-09-26 03:50:29 +07:00
parent 5259c86365
commit 0e1881d837
2 changed files with 97 additions and 21 deletions
+68 -20
View File
@@ -1,11 +1,13 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart';
/// A playback-driven countdown, rather than a repeating loading animation.
class LyricGapIndicator extends ConsumerWidget {
/// Playback controls the fill; a gentle pulse accompanies instrumental gaps.
class LyricGapIndicator extends ConsumerStatefulWidget {
const LyricGapIndicator({
super.key,
required this.start,
@@ -20,16 +22,44 @@ class LyricGapIndicator extends ConsumerWidget {
final Duration? position;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<LyricGapIndicator> createState() => _LyricGapIndicatorState();
}
class _LyricGapIndicatorState extends ConsumerState<LyricGapIndicator>
with SingleTickerProviderStateMixin {
late final _pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
@override
void dispose() {
_pulse.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Duration position =
widget.position ?? ref.watch(playbackPositionProvider);
final progress = syncedLyricSegmentProgress(
position: position ?? ref.watch(playbackPositionProvider),
start: start,
end: end,
position: position,
start: widget.start,
end: widget.end,
);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final animate =
ref.watch(playbackPlayingProvider) &&
!ref.watch(playbackLoadingProvider) &&
!MediaQuery.disableAnimationsOf(context);
!reduceMotion &&
widget.position == null &&
position >= widget.start &&
position < widget.end;
if (animate) {
if (!_pulse.isAnimating) _pulse.repeat();
} else {
_pulse.stop();
}
return Semantics(
label: context.l10n.nowPlayingInstrumental,
value: '${(progress * 100).round()}%',
@@ -38,19 +68,37 @@ class LyricGapIndicator extends ConsumerWidget {
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) const SizedBox(width: 8),
AnimatedContainer(
key: ValueKey('lyric-gap-dot-$index'),
duration: animate
? const Duration(milliseconds: 120)
: Duration.zero,
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.lerp(
color.withValues(alpha: 0.25),
color,
(progress * 3 - index).clamp(0.0, 1.0),
AnimatedBuilder(
animation: _pulse,
builder: (context, child) {
// Stagger the breath without changing the row's layout or
// letting the pulse advance the playback-driven fill.
final phase = ((_pulse.value - index * 0.12) / 0.76).clamp(
0.0,
1.0,
);
final breath = math.sin(math.pi * phase);
return Transform.scale(
scale: reduceMotion || widget.position != null
? 1
: 1 + 0.24 * breath * breath,
child: child,
);
},
child: AnimatedContainer(
key: ValueKey('lyric-gap-dot-$index'),
duration: animate
? const Duration(milliseconds: 120)
: Duration.zero,
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.lerp(
widget.color.withValues(alpha: 0.25),
widget.color,
(progress * 3 - index).clamp(0.0, 1.0),
),
),
),
),
+29 -1
View File
@@ -1235,7 +1235,7 @@ void main() {
updatePosition: Duration(seconds: seconds),
),
);
if (state == AudioProcessingState.ready) {
if (state == AudioProcessingState.ready && !playing) {
await tester.pumpAndSettle();
} else {
await tester.pump();
@@ -1273,6 +1273,34 @@ void main() {
}
}
final firstDot = find.byKey(const ValueKey('lyric-gap-dot-0'));
double dotWidth() =>
(tester.getBottomRight(firstDot) - tester.getTopLeft(firstDot)).dx;
final restingWidth = dotWidth();
final restingCenter = tester.getCenter(firstDot);
final rowSize = tester.getSize(find.byType(LyricGapIndicator));
await positionAt(6, playing: true);
await tester.pump(const Duration(milliseconds: 400));
final filling = dotAlphas();
final growingWidth = dotWidth();
expect(growingWidth, greaterThan(restingWidth));
await tester.pump(const Duration(milliseconds: 350));
final peakWidth = dotWidth();
expect(peakWidth, greaterThan(growingWidth));
await tester.pump(const Duration(milliseconds: 750));
expect(dotWidth(), lessThan(peakWidth));
expect(tester.getCenter(firstDot), restingCenter);
expect(tester.getSize(find.byType(LyricGapIndicator)), rowSize);
expect(dotAlphas(), filling);
// Pause mid-pulse: both the size and countdown must hold their place.
await tester.pump(const Duration(milliseconds: 900));
await positionAt(6);
final pausedWidth = dotWidth();
await tester.pump(const Duration(seconds: 2));
expect(dotWidth(), pausedWidth);
expect(dotAlphas(), [1.0, 1.0, 0.25]);
await positionAt(6, playing: true, state: AudioProcessingState.buffering);
expect(find.byType(LyricGapIndicator), findsNothing);
await positionAt(6);