407 lines
12 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |