Files
2026-05-18 09:55:25 +01:00

407 lines
12 KiB
Dart

// 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,
),
),
],
),
),
),
);
}
}