Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
// }
|
||||
}
|
||||
@@ -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