Initial commit
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// lib/widgets/tonearm.dart
|
||||
//
|
||||
// Tonearm that toggles play/pause on tap.
|
||||
// Animates smoothly between lifted (paused) and on-record (playing) positions.
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Tonearm extends StatefulWidget {
|
||||
final bool isPlaying;
|
||||
final ValueChanged<bool>? onToggle;
|
||||
|
||||
const Tonearm({super.key, required this.isPlaying, this.onToggle});
|
||||
|
||||
@override
|
||||
State<Tonearm> createState() => _TonearmState();
|
||||
}
|
||||
|
||||
class _TonearmState extends State<Tonearm>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
static const _restAngle = -0.38;
|
||||
static const _playAngle = 0.05;
|
||||
static const _angleRange = _playAngle - _restAngle;
|
||||
|
||||
double get _currentAngle => _restAngle + _controller.value * _angleRange;
|
||||
|
||||
Size _lastSize = const Size(300, 300);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
value: widget.isPlaying ? 1.0 : 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Tonearm old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.isPlaying != old.isPlaying) {
|
||||
widget.isPlaying ? _controller.forward() : _controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ─── Hit test — tap anywhere near the arm body ────────────────────────────
|
||||
|
||||
bool _isNearArm(Offset touch) {
|
||||
final unit = _lastSize.shortestSide;
|
||||
final isLandscape = _lastSize.width > _lastSize.height;
|
||||
final ref = isLandscape ? _lastSize.height : _lastSize.width;
|
||||
final pivot = isLandscape
|
||||
? Offset(_lastSize.width * 0.5 + ref * 0.42, _lastSize.height * 0.12)
|
||||
: Offset(_lastSize.width * 0.8, _lastSize.height * 0.12);
|
||||
final armLength = unit * 0.5;
|
||||
final hitRadius = unit * 0.14;
|
||||
final a = _currentAngle;
|
||||
|
||||
Offset toCanvas(double lx, double ly) {
|
||||
final rx = lx * math.cos(a) - ly * math.sin(a);
|
||||
final ry = lx * math.sin(a) + ly * math.cos(a);
|
||||
return pivot + Offset(rx, ry);
|
||||
}
|
||||
|
||||
final p0 = pivot;
|
||||
final p1 = toCanvas(-armLength * 0.15, armLength * 0.75);
|
||||
final p2 = toCanvas(-armLength * 0.22, armLength);
|
||||
final p3 = toCanvas(-armLength * 0.28, armLength * 1.09);
|
||||
|
||||
return _distToSegment(touch, p0, p1) < hitRadius ||
|
||||
_distToSegment(touch, p1, p2) < hitRadius ||
|
||||
_distToSegment(touch, p2, p3) < hitRadius;
|
||||
}
|
||||
|
||||
double _distToSegment(Offset p, Offset a, Offset b) {
|
||||
final ab = b - a;
|
||||
final ap = p - a;
|
||||
final t = (ap.dx * ab.dx + ap.dy * ab.dy) /
|
||||
(ab.dx * ab.dx + ab.dy * ab.dy + 1e-9);
|
||||
return (p - (a + ab * t.clamp(0.0, 1.0))).distance;
|
||||
}
|
||||
|
||||
void _onTap(Offset localPosition) {
|
||||
if (!_isNearArm(localPosition)) return;
|
||||
final next = !widget.isPlaying;
|
||||
widget.onToggle?.call(next);
|
||||
}
|
||||
|
||||
// ─── Build ────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
_lastSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapUp: (d) => _onTap(d.localPosition),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) => CustomPaint(
|
||||
painter: _TonearmPainter(angle: _currentAngle, size: _lastSize),
|
||||
size: _lastSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Painter ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TonearmPainter extends CustomPainter {
|
||||
final double angle;
|
||||
final Size size;
|
||||
_TonearmPainter({required this.angle, required this.size});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final unit = size.shortestSide;
|
||||
final isLandscape = size.width > size.height;
|
||||
final ref = isLandscape ? size.height : size.width;
|
||||
final pivot = isLandscape
|
||||
? Offset(size.width * 0.5 + ref * 0.42, size.height * 0.12)
|
||||
: Offset(size.width * 0.8, size.height * 0.12);
|
||||
final armLength = unit * 0.5;
|
||||
final headshellExt = armLength * 0.09;
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(pivot.dx, pivot.dy);
|
||||
canvas.rotate(angle);
|
||||
|
||||
final arm = Paint()
|
||||
..strokeCap = StrokeCap.round
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
canvas.drawLine(Offset.zero,
|
||||
Offset(-armLength * 0.15, armLength * 0.75),
|
||||
arm..color = const Color(0xFFB8B8B8)..strokeWidth = unit * 0.012);
|
||||
canvas.drawLine(
|
||||
Offset(-armLength * 0.15, armLength * 0.75),
|
||||
Offset(-armLength * 0.22, armLength),
|
||||
arm..strokeWidth = unit * 0.009);
|
||||
canvas.drawLine(
|
||||
Offset(-armLength * 0.22, armLength),
|
||||
Offset(-armLength * 0.28, armLength + headshellExt),
|
||||
arm..color = const Color(0xFF888888)..strokeWidth = unit * 0.008);
|
||||
canvas.drawCircle(
|
||||
Offset(-armLength * 0.28, armLength + headshellExt * 1.2),
|
||||
unit * 0.009,
|
||||
Paint()..color = const Color(0xFFE0E0E0));
|
||||
canvas.drawCircle(Offset.zero, unit * 0.022, Paint()..color = const Color(0xFF999999));
|
||||
canvas.drawCircle(Offset.zero, unit * 0.013, Paint()..color = const Color(0xFF555555));
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_TonearmPainter old) =>
|
||||
old.angle != angle || old.size != size;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user