Initial commit

This commit is contained in:
2026-05-18 09:37:50 +01:00
commit 562e585309
121 changed files with 6226 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'services/media_service.dart';
import 'screens/player_screen.dart';
import 'screens/permission_screen.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Force portrait
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
runApp(const OpenVinylApp());
}
class OpenVinylApp extends StatelessWidget {
const OpenVinylApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OpenVinyl',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true),
home: AppEntry(mediaService: MediaService()),
);
}
}
/// Checks notification permission on startup, shows the right screen.
class AppEntry extends StatefulWidget {
final MediaService mediaService;
const AppEntry({super.key, required this.mediaService});
@override
State<AppEntry> createState() => _AppEntryState();
}
class _AppEntryState extends State<AppEntry> with WidgetsBindingObserver {
bool? _hasPermission; // null = loading
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkPermission();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// Re-check permission when the user returns from system settings
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _hasPermission == false) {
_checkPermission();
}
}
Future<void> _checkPermission() async {
final granted = await widget.mediaService.hasPermission();
setState(() => _hasPermission = granted);
}
@override
Widget build(BuildContext context) {
if (_hasPermission == null) {
// Still loading
return const Scaffold(
backgroundColor: Color(0xFF0D0D0D),
body: Center(
child: CircularProgressIndicator(color: Colors.white30),
),
);
}
if (!_hasPermission!) {
return PermissionScreen(
mediaService: widget.mediaService,
onPermissionGranted: () => setState(() => _hasPermission = true),
);
}
return PlayerScreen(mediaService: widget.mediaService);
}
}
+49
View File
@@ -0,0 +1,49 @@
// lib/models/media_info.dart
//
// Represents the currently playing track's metadata.
// Populated by NotificationListenerService via platform channel.
import 'dart:typed_data';
class MediaInfo {
final String title;
final String artist;
final String album;
final Uint8List? albumArt; // Raw bytes from notification
final bool isPlaying;
const MediaInfo({
required this.title,
required this.artist,
required this.album,
this.albumArt,
required this.isPlaying,
});
// Empty/idle state
static const empty = MediaInfo(
title: 'Nothing Playing',
artist: '',
album: '',
isPlaying: false,
);
MediaInfo copyWith({
String? title,
String? artist,
String? album,
Uint8List? albumArt,
bool? isPlaying,
}) {
return MediaInfo(
title: title ?? this.title,
artist: artist ?? this.artist,
album: album ?? this.album,
albumArt: albumArt ?? this.albumArt,
isPlaying: isPlaying ?? this.isPlaying,
);
}
@override
String toString() => 'MediaInfo(title: $title, artist: $artist, isPlaying: $isPlaying)';
}
+88
View File
@@ -0,0 +1,88 @@
// lib/screens/permission_screen.dart
//
// Shown when the user hasn't yet granted Notification Listener access.
// Explains why it's needed and opens the system settings page.
import 'package:flutter/material.dart';
import '../services/media_service.dart';
class PermissionScreen extends StatelessWidget {
final MediaService mediaService;
final VoidCallback onPermissionGranted;
const PermissionScreen({
super.key,
required this.mediaService,
required this.onPermissionGranted,
});
Future<void> _openSettings(BuildContext context) async {
await mediaService.requestPermission();
// Poll after a short delay — the user may have just come back from settings
await Future.delayed(const Duration(seconds: 1));
final granted = await mediaService.hasPermission();
if (granted) onPermissionGranted();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D0D0D),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(36),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.notifications_active_outlined,
color: Colors.white54,
size: 72,
),
const SizedBox(height: 32),
const Text(
'Notification Access Needed',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'OpenVinyl reads your currently playing music from your notification shade — '
'it never accesses your messages or other notifications.\n\n'
'Tap below to open system settings and enable access for OpenVinyl.',
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 15,
height: 1.55,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
FilledButton.icon(
onPressed: () => _openSettings(context),
icon: const Icon(Icons.settings_outlined),
label: const Text('Open Settings'),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 14,
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}
+407
View File
@@ -0,0 +1,407 @@
// lib/screens/player_screen.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../models/media_info.dart';
import '../services/media_service.dart';
import '../widgets/vinyl_record.dart';
import '../widgets/tonearm.dart';
class PlayerScreen extends StatefulWidget {
final MediaService mediaService;
const PlayerScreen({super.key, required this.mediaService});
@override
State<PlayerScreen> createState() => _PlayerScreenState();
}
class _PlayerScreenState extends State<PlayerScreen> {
MediaInfo _current = MediaInfo.empty;
Color _bgTop = const Color(0xFF1A1A2E);
Color _bgBottom = const Color(0xFF0D0D0D);
bool _wakeLockOn = false;
Timer? _paletteDebounce;
@override
void initState() {
super.initState();
_loadInitial();
widget.mediaService.mediaStream.listen(_onMediaUpdate);
}
@override
void dispose() {
_paletteDebounce?.cancel();
WakelockPlus.disable();
super.dispose();
}
Future<void> _loadInitial() async {
final info = await widget.mediaService.getCurrentMedia();
_onMediaUpdate(info);
}
void _onMediaUpdate(MediaInfo info) {
setState(() => _current = info);
_paletteDebounce?.cancel();
_paletteDebounce = Timer(
const Duration(milliseconds: 300),
() => _updatePalette(info),
);
}
Future<void> _updatePalette(MediaInfo info) async {
if (info.albumArt == null) {
if (mounted) setState(() {
_bgTop = const Color(0xFF1A1A2E);
_bgBottom = const Color(0xFF0D0D0D);
});
return;
}
try {
final generator = await PaletteGenerator.fromImageProvider(
MemoryImage(info.albumArt!),
maximumColorCount: 8,
);
if (!mounted) return;
final dominant = generator.dominantColor?.color ?? const Color(0xFF1A1A2E);
final muted = generator.mutedColor?.color ?? const Color(0xFF0D0D0D);
setState(() {
_bgTop = _darken(dominant, 0.45);
_bgBottom = _darken(muted, 0.7);
});
} catch (_) {}
}
void _toggleWakeLock() {
final next = !_wakeLockOn;
setState(() => _wakeLockOn = next);
WakelockPlus.toggle(enable: next);
}
Color _darken(Color c, double amount) {
final hsl = HSLColor.fromColor(c);
return hsl.withLightness((hsl.lightness * (1 - amount)).clamp(0.0, 1.0)).toColor();
}
@override
Widget build(BuildContext context) {
final isLandscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
return Scaffold(
body: AnimatedContainer(
duration: const Duration(milliseconds: 800),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [_bgTop, _bgBottom],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: isLandscape
? _LandscapeLayout(
info: _current,
mediaService: widget.mediaService,
wakeLockOn: _wakeLockOn,
onWakeLockTap: _toggleWakeLock,
)
: _PortraitLayout(
info: _current,
mediaService: widget.mediaService,
wakeLockOn: _wakeLockOn,
onWakeLockTap: _toggleWakeLock,
),
),
),
);
}
}
// ─── Portrait layout (original behaviour) ────────────────────────────────────
class _PortraitLayout extends StatelessWidget {
final MediaInfo info;
final MediaService mediaService;
final bool wakeLockOn;
final VoidCallback onWakeLockTap;
const _PortraitLayout({
required this.info,
required this.mediaService,
required this.wakeLockOn,
required this.onWakeLockTap,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Flexible(flex: 2, child: SizedBox(height: 32)),
_TrackInfo(info: info),
const Flexible(flex: 2, child: SizedBox(height: 32)),
_TurntableStage(info: info, mediaService: mediaService),
const Flexible(flex: 3, child: SizedBox(height: 48)),
],
),
Positioned(
left: 20,
bottom: 20,
child: _WakeLockButton(isOn: wakeLockOn, onTap: onWakeLockTap),
),
],
);
}
}
// ─── Landscape layout — vinyl centred, track info above ──────────────────────
//
// In landscape the screen is wide and short. We:
// - Centre the vinyl disc (no left-shift, it fits without clipping needed)
// - Size it to 90% of screen height so it fills the shorter dimension
// - Place track info in a compact row above the disc
// - Keep the wake lock button bottom-left
class _LandscapeLayout extends StatelessWidget {
final MediaInfo info;
final MediaService mediaService;
final bool wakeLockOn;
final VoidCallback onWakeLockTap;
const _LandscapeLayout({
required this.info,
required this.mediaService,
required this.wakeLockOn,
required this.onWakeLockTap,
});
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final stageSize = (size.height * 0.82).clamp(0.0, size.width * 0.75);
return Stack(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 8),
_TrackInfo(info: info, centred: true),
const SizedBox(height: 12),
// Centred disc — no shift, tonearm sits within the stage
SizedBox(
width: size.width,
height: stageSize,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
// Record — centred
Positioned(
left: (size.width - stageSize) / 2,
top: 0,
width: stageSize,
height: stageSize,
child: Padding(
padding: EdgeInsets.all(stageSize * 0.04),
child: RepaintBoundary(
child: VinylRecord(
albumArt: info.albumArt,
isPlaying: info.isPlaying,
),
),
),
),
// Tonearm — full width so pivot position stays consistent
Positioned.fill(
child: Tonearm(
isPlaying: info.isPlaying,
onToggle: (_) => mediaService.playPause(),
),
),
],
),
),
],
),
Positioned(
left: 20,
bottom: 12,
child: _WakeLockButton(isOn: wakeLockOn, onTap: onWakeLockTap),
),
],
);
}
}
// ─── Track info ───────────────────────────────────────────────────────────────
class _TrackInfo extends StatelessWidget {
final MediaInfo info;
final bool centred;
const _TrackInfo({required this.info, this.centred = false});
@override
Widget build(BuildContext context) {
final screenW = MediaQuery.sizeOf(context).width;
final screenH = MediaQuery.sizeOf(context).height;
final ref = screenW.clamp(0.0, screenH * 0.5);
final titleSize = (ref * 0.072).clamp(16.0, 36.0);
final subSize = (ref * 0.050).clamp(12.0, 24.0);
final align = centred ? TextAlign.center : TextAlign.start;
final crossAxis = centred
? CrossAxisAlignment.center
: CrossAxisAlignment.start;
return Padding(
padding: centred
? const EdgeInsets.symmetric(horizontal: 24)
: const EdgeInsets.only(left: 24, right: 80),
child: Column(
crossAxisAlignment: crossAxis,
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Text(
info.title,
key: ValueKey(info.title),
textAlign: align,
style: TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Text(
info.artist.isNotEmpty ? info.artist : ' ',
key: ValueKey(info.artist),
textAlign: align,
style: TextStyle(
color: Colors.white.withOpacity(0.65),
fontSize: subSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
// ─── Turntable stage (portrait) ───────────────────────────────────────────────
class _TurntableStage extends StatelessWidget {
final MediaInfo info;
final MediaService mediaService;
const _TurntableStage({required this.info, required this.mediaService});
static const _shiftFraction = 0.18;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final screenW = size.width;
final screenH = size.height;
final stageSize = screenW.clamp(0.0, screenH * 0.80);
final shift = stageSize * _shiftFraction;
return SizedBox(
width: screenW,
height: stageSize,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: -shift,
top: 0,
width: stageSize,
height: stageSize,
child: Padding(
padding: EdgeInsets.all(stageSize * 0.04),
child: RepaintBoundary(
child: VinylRecord(
albumArt: info.albumArt,
isPlaying: info.isPlaying,
),
),
),
),
Positioned.fill(
child: Tonearm(
isPlaying: info.isPlaying,
onToggle: (_) => mediaService.playPause(),
),
),
],
),
);
}
}
// ─── Wake lock button ─────────────────────────────────────────────────────────
class _WakeLockButton extends StatelessWidget {
final bool isOn;
final VoidCallback onTap;
const _WakeLockButton({required this.isOn, required this.onTap});
@override
Widget build(BuildContext context) {
return Tooltip(
message: isOn ? 'Tap to allow screen sleep' : 'Keep screen on',
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isOn
? Colors.white.withOpacity(0.15)
: Colors.white.withOpacity(0.06),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isOn
? Colors.white.withOpacity(0.5)
: Colors.white.withOpacity(0.15),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isOn ? Icons.lock_open_rounded : Icons.lock_clock_outlined,
color: isOn ? Colors.white : Colors.white38,
size: 16,
),
const SizedBox(width: 6),
Text(
'Screen on',
style: TextStyle(
color: isOn ? Colors.white : Colors.white38,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
// lib/services/media_service.dart
import 'dart:typed_data';
import 'package:flutter/services.dart';
import '../models/media_info.dart';
// Uncomment when adding widgets:
// import 'package:home_widget/home_widget.dart';
class MediaService {
static const _methodChannel = MethodChannel('pt.ruifpb.openvinyl/media_control');
static const _eventChannel = EventChannel('pt.ruifpb.openvinyl/media_events');
Stream<MediaInfo>? _mediaStream;
Stream<MediaInfo> get mediaStream {
_mediaStream ??= _eventChannel
.receiveBroadcastStream()
.map(_parseEvent)
.where((info) => info != null)
.cast<MediaInfo>();
return _mediaStream!;
}
Future<bool> hasPermission() async {
try {
return await _methodChannel.invokeMethod<bool>('hasPermission') ?? false;
} on PlatformException catch (e) {
print('MediaService.hasPermission error: $e');
return false;
}
}
Future<void> requestPermission() async {
try {
await _methodChannel.invokeMethod('requestPermission');
} on PlatformException catch (e) {
print('MediaService.requestPermission error: $e');
}
}
Future<MediaInfo> getCurrentMedia() async {
try {
final data = await _methodChannel.invokeMethod<Map>('getCurrentMedia');
if (data == null) return MediaInfo.empty;
return _parseMap(Map<String, dynamic>.from(data));
} on PlatformException catch (e) {
print('MediaService.getCurrentMedia error: $e');
return MediaInfo.empty;
}
}
/// Sends a play or pause command to the active MediaSession.
/// Android decides which based on the current playback state,
/// so this is a true toggle.
Future<void> playPause() async {
try {
await _methodChannel.invokeMethod('playPause');
} on PlatformException catch (e) {
print('MediaService.playPause error: $e');
}
}
// ─── Private helpers ──────────────────────────────────────────────────────
MediaInfo? _parseEvent(dynamic event) {
if (event == null) return null;
try {
final map = Map<String, dynamic>.from(event as Map);
final info = _parseMap(map);
// _pushToWidget(info); // uncomment when adding widgets
return info;
} catch (e) {
print('MediaService._parseEvent error: $e');
return null;
}
}
MediaInfo _parseMap(Map<String, dynamic> map) {
Uint8List? albumArt;
if (map['albumArt'] != null) {
albumArt = Uint8List.fromList(List<int>.from(map['albumArt']));
}
return MediaInfo(
title: map['title'] as String? ?? 'Unknown Title',
artist: map['artist'] as String? ?? 'Unknown Artist',
album: map['album'] as String? ?? '',
albumArt: albumArt,
isPlaying: map['isPlaying'] as bool? ?? false,
);
}
// ── WIDGET HOOK ────────────────────────────────────────────────────────────
// Future<void> _pushToWidget(MediaInfo info) async {
// await HomeWidget.saveWidgetData('title', info.title);
// await HomeWidget.saveWidgetData('artist', info.artist);
// await HomeWidget.updateWidget(androidName: 'VinylWidgetReceiver');
// }
}
+169
View File
@@ -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;
}
+262
View File
@@ -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;
}