241 lines
9.8 KiB
TypeScript
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)} />
|
|
</>
|
|
)
|
|
}
|