262 lines
7.4 KiB
Dart
262 lines
7.4 KiB
Dart
// lib/widgets/vinyl_record.dart
|
|
//
|
|
// Jitter fixes applied:
|
|
//
|
|
// 1. The rotation Transform is now the ONLY thing AnimatedBuilder rebuilds.
|
|
// The disc and album art are passed as a static `child` — Flutter's
|
|
// AnimatedBuilder skips rebuilding the child subtree on every tick,
|
|
// so album art image decoding / layout never competes with the animation.
|
|
//
|
|
// 2. Album art crossfade is handled by AnimatedSwitcher inside the label,
|
|
// keyed on the art bytes identity — it fades smoothly without touching
|
|
// the rotation controller.
|
|
//
|
|
// 3. _VinylPainter is cached via RepaintBoundary so the groove canvas
|
|
// is only repainted when size changes, not on every rotation frame.
|
|
|
|
import 'dart:math' as math;
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
|
|
class VinylRecord extends StatefulWidget {
|
|
final Uint8List? albumArt;
|
|
final bool isPlaying;
|
|
final Duration rotationDuration;
|
|
|
|
const VinylRecord({
|
|
super.key,
|
|
this.albumArt,
|
|
required this.isPlaying,
|
|
this.rotationDuration = const Duration(milliseconds: 1800),
|
|
});
|
|
|
|
@override
|
|
State<VinylRecord> createState() => _VinylRecordState();
|
|
}
|
|
|
|
class _VinylRecordState extends State<VinylRecord>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: widget.rotationDuration,
|
|
);
|
|
if (widget.isPlaying) _controller.repeat();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(VinylRecord old) {
|
|
super.didUpdateWidget(old);
|
|
if (widget.isPlaying && !_controller.isAnimating) {
|
|
_controller.repeat();
|
|
} else if (!widget.isPlaying && _controller.isAnimating) {
|
|
_controller
|
|
.animateTo(
|
|
1.0,
|
|
duration: const Duration(milliseconds: 600),
|
|
curve: Curves.easeOut,
|
|
)
|
|
.then((_) {
|
|
if (!widget.isPlaying) _controller.reset();
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final size = math.min(constraints.maxWidth, constraints.maxHeight);
|
|
|
|
// Build the static disc once and pass it as child —
|
|
// AnimatedBuilder will NOT call builder on this subtree each tick.
|
|
final disc = _VinylDisc(size: size, albumArt: widget.albumArt);
|
|
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
child: disc, // <-- built once, reused every frame
|
|
builder: (context, child) {
|
|
return Transform.rotate(
|
|
angle: _controller.value * 2 * math.pi,
|
|
child: child,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Static disc (grooves + label) ───────────────────────────────────────────
|
|
|
|
class _VinylDisc extends StatelessWidget {
|
|
final double size;
|
|
final Uint8List? albumArt;
|
|
|
|
const _VinylDisc({required this.size, this.albumArt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox.square(
|
|
dimension: size,
|
|
child: Stack(
|
|
children: [
|
|
// Grooves — wrapped in RepaintBoundary so it is rasterised once
|
|
// and never redrawn unless size changes
|
|
Positioned.fill(
|
|
child: RepaintBoundary(
|
|
child: CustomPaint(painter: _VinylPainter()),
|
|
),
|
|
),
|
|
// Album art label in the centre
|
|
Center(
|
|
child: _AlbumArtLabel(size: size * 0.38, albumArt: albumArt),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Album art label with crossfade ──────────────────────────────────────────
|
|
|
|
class _AlbumArtLabel extends StatelessWidget {
|
|
final double size;
|
|
final Uint8List? albumArt;
|
|
|
|
const _AlbumArtLabel({required this.size, this.albumArt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox.square(
|
|
dimension: size,
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 500),
|
|
child: _LabelCircle(
|
|
// Key on the bytes reference so switcher triggers on art change
|
|
key: ValueKey(albumArt?.hashCode),
|
|
size: size,
|
|
albumArt: albumArt,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LabelCircle extends StatelessWidget {
|
|
final double size;
|
|
final Uint8List? albumArt;
|
|
|
|
const _LabelCircle({super.key, required this.size, this.albumArt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: const Color(0xFF1A1A1A),
|
|
image: albumArt != null
|
|
? DecorationImage(
|
|
image: MemoryImage(albumArt!),
|
|
fit: BoxFit.cover,
|
|
)
|
|
: null,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.4),
|
|
blurRadius: 8,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: albumArt == null
|
|
? const Center(
|
|
child: Icon(Icons.music_note, color: Colors.white38, size: 32),
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Groove painter — shouldRepaint = false, wrapped in RepaintBoundary ───────
|
|
|
|
class _VinylPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final centre = Offset(size.width / 2, size.height / 2);
|
|
final radius = size.width / 2;
|
|
|
|
// Base disc
|
|
canvas.drawCircle(
|
|
centre,
|
|
radius,
|
|
Paint()..color = const Color(0xFF0D0D0D),
|
|
);
|
|
|
|
// Soft radial fade at the outer edge — blends into the background
|
|
// instead of leaving a hard ring
|
|
final edgeFade = Paint()
|
|
..shader = RadialGradient(
|
|
center: Alignment.center,
|
|
radius: 1.0,
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.transparent,
|
|
Colors.black.withOpacity(0.45),
|
|
Colors.black.withOpacity(0.85),
|
|
],
|
|
stops: const [0.0, 0.78, 0.92, 1.0],
|
|
).createShader(Rect.fromCircle(center: centre, radius: radius));
|
|
canvas.drawCircle(centre, radius, edgeFade);
|
|
|
|
final groovePaint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 0.6;
|
|
|
|
const grooveCount = 28;
|
|
final innerEdge = radius * 0.42;
|
|
final outerEdge = radius * 0.94;
|
|
final step = (outerEdge - innerEdge) / grooveCount;
|
|
|
|
for (int i = 0; i < grooveCount; i++) {
|
|
final r = innerEdge + i * step;
|
|
final brightness = (i % 2 == 0) ? 0x2A : 0x22;
|
|
groovePaint.color = Color.fromARGB(255, brightness, brightness, brightness);
|
|
canvas.drawCircle(centre, r, groovePaint);
|
|
}
|
|
|
|
// Sheen
|
|
final sheen = Paint()
|
|
..shader = RadialGradient(
|
|
center: const Alignment(-0.35, -0.35),
|
|
radius: 0.85,
|
|
colors: [Colors.white.withOpacity(0.07), Colors.transparent],
|
|
).createShader(Rect.fromCircle(center: centre, radius: radius));
|
|
canvas.drawCircle(centre, radius, sheen);
|
|
|
|
// Rim
|
|
canvas.drawCircle(
|
|
centre,
|
|
radius - 1,
|
|
Paint()
|
|
..color = Colors.white.withOpacity(0.08)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.5,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_VinylPainter old) => false;
|
|
} |