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),
),
),
),
),