jefflix-website/components/music/full-screen-player.tsx

241 lines
9.8 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Drawer } from 'vaul'
import { useMusicPlayer } from './music-provider'
import { DownloadButton } from './download-button'
import { PlaylistPicker } from './playlist-picker'
import { SyncedLyrics } from './synced-lyrics'
import { QueueView } from './queue-view'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import {
Play,
Pause,
SkipBack,
SkipForward,
ListPlus,
ListMusic,
Share2,
Volume2,
VolumeX,
ChevronDown,
Speaker,
Shuffle,
} from 'lucide-react'
function formatTime(secs: number) {
if (!secs || !isFinite(secs)) return '0:00'
const m = Math.floor(secs / 60)
const s = Math.floor(secs % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export function FullScreenPlayer() {
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, toggleShuffle, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
const router = useRouter()
const [lyrics, setLyrics] = useState<string | null>(null)
const [syncedLyrics, setSyncedLyrics] = useState<string | null>(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
const [queueOpen, setQueueOpen] = useState(false)
const track = state.currentTrack
// Fetch lyrics when track changes
useEffect(() => {
if (!track) return
setLyrics(null)
setSyncedLyrics(null)
setLoadingLyrics(true)
fetch(`/api/music/lyrics?artist=${encodeURIComponent(track.artist)}&title=${encodeURIComponent(track.title)}`)
.then((r) => r.json())
.then((d) => {
setLyrics(d.lyrics)
setSyncedLyrics(d.synced || null)
})
.catch(() => { setLyrics(null); setSyncedLyrics(null) })
.finally(() => setLoadingLyrics(false))
}, [track?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const handleShare = async () => {
if (!track) return
const text = `${track.title} - ${track.artist}`
if (navigator.share) {
try {
await navigator.share({ title: text, text })
} catch {}
} else {
await navigator.clipboard.writeText(text)
}
}
return (
<>
<Drawer.Root open={state.isFullScreen} onOpenChange={setFullScreen}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/60 z-50" />
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 flex max-h-[96vh] flex-col rounded-t-2xl bg-background border-t border-border">
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30" />
<Drawer.Title className="sr-only">Music Player</Drawer.Title>
<Drawer.Description className="sr-only">
Full-screen music player with controls, lyrics, and playlist management
</Drawer.Description>
{track && (
<div className="flex flex-col items-center px-6 pb-8 pt-4 overflow-y-auto">
{/* Close button */}
<button
onClick={() => setFullScreen(false)}
className="self-start mb-4 p-1 rounded-full hover:bg-muted/50 transition-colors"
>
<ChevronDown className="h-6 w-6 text-muted-foreground" />
</button>
{/* Album art */}
<div className="w-64 h-64 sm:w-72 sm:h-72 rounded-xl overflow-hidden shadow-2xl mb-8 bg-muted flex-shrink-0">
{track.coverArt ? (
<img
src={`/api/music/cover/${track.coverArt}?size=600`}
alt={track.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Volume2 className="h-16 w-16" />
</div>
)}
</div>
{/* Title / Artist */}
<div className="text-center mb-6 max-w-sm">
<h2 className="text-xl font-bold truncate">{track.title}</h2>
<button
className="text-muted-foreground truncate hover:text-purple-400 hover:underline transition-colors"
onClick={() => {
setFullScreen(false)
router.push(`/music?q=${encodeURIComponent(track.artist)}`)
}}
>
{track.artist}
</button>
<p className="text-sm text-muted-foreground/70 truncate">{track.album}</p>
</div>
{/* Progress */}
<div className="w-full max-w-sm mb-4">
<Slider
value={[state.progress]}
max={state.duration || 1}
step={1}
onValueChange={([v]) => seek(v)}
className="mb-2"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatTime(state.progress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-6 mb-6">
<button
onClick={toggleShuffle}
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
>
<Shuffle className="h-5 w-5" />
</button>
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipBack className="h-6 w-6" />
</button>
<button
onClick={togglePlay}
className="p-4 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
>
{state.isPlaying ? <Pause className="h-7 w-7" /> : <Play className="h-7 w-7 ml-0.5" />}
</button>
<button onClick={nextTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipForward className="h-6 w-6" />
</button>
</div>
{/* Volume */}
<div className="flex items-center gap-3 w-full max-w-[200px] mb-6">
<button onClick={() => setVolume(state.volume === 0 ? 0.8 : 0)} className="text-muted-foreground">
{state.volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<Slider
value={[state.volume * 100]}
max={100}
step={1}
onValueChange={([v]) => setVolume(v / 100)}
/>
</div>
{/* Audio output selector */}
{outputDevices.length > 1 && (
<div className="flex items-center gap-2 w-full max-w-sm mb-6">
<Speaker className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<select
value={currentOutputId || 'default'}
onChange={(e) => setOutputDevice(e.target.value)}
className="flex-1 text-sm bg-muted/50 border border-border rounded-md px-2 py-1.5 text-foreground focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{outputDevices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
</div>
)}
{/* Actions */}
<div className="flex gap-3 mb-8 flex-wrap justify-center">
<Button variant="outline" size="sm" onClick={() => setQueueOpen(true)}>
<ListMusic className="h-4 w-4 mr-1.5" />
Queue
</Button>
<DownloadButton track={track} size="md" />
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
<ListPlus className="h-4 w-4 mr-1.5" />
Add to Playlist
</Button>
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="h-4 w-4 mr-1.5" />
Share
</Button>
</div>
{/* Lyrics */}
{loadingLyrics ? (
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
) : syncedLyrics ? (
<SyncedLyrics
syncedLyrics={syncedLyrics}
currentTime={state.progress}
onSeek={seek}
/>
) : lyrics ? (
<div className="w-full max-w-sm">
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>
<pre className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground font-sans">
{lyrics}
</pre>
</div>
) : null}
</div>
)}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
<PlaylistPicker open={playlistOpen} onOpenChange={setPlaylistOpen} />
<QueueView open={queueOpen} onClose={() => setQueueOpen(false)} />
</>
)
}