Initial commit
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user