// 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 createState() => _PlayerScreenState(); } class _PlayerScreenState extends State { 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 _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 _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, ), ), ], ), ), ), ); } }