mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
fix(lyrics): pulse instrumental countdown dots
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user