295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { ChevronDown, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Trash2, Volume2 } from 'lucide-react'
|
|
import { useMemo, useState } from 'react'
|
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
|
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
|
import { usePlayerStore } from '@/stores/player-store'
|
|
|
|
type LyricsLine = {
|
|
time: number
|
|
text: string
|
|
}
|
|
|
|
export function FullPlayer() {
|
|
const currentTrack = usePlayerStore((state) => state.currentTrack)
|
|
const queue = usePlayerStore((state) => state.queue)
|
|
const isPlaying = usePlayerStore((state) => state.isPlaying)
|
|
const currentTime = usePlayerStore((state) => state.currentTime)
|
|
const duration = usePlayerStore((state) => state.duration)
|
|
const volume = usePlayerStore((state) => state.volume)
|
|
const shuffle = usePlayerStore((state) => state.shuffle)
|
|
const repeatMode = usePlayerStore((state) => state.repeatMode)
|
|
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
|
|
const playNext = usePlayerStore((state) => state.playNext)
|
|
const playPrevious = usePlayerStore((state) => state.playPrevious)
|
|
const playAtIndex = usePlayerStore((state) => state.playAtIndex)
|
|
const removeFromQueue = usePlayerStore((state) => state.removeFromQueue)
|
|
const toggleShuffle = usePlayerStore((state) => state.toggleShuffle)
|
|
const cycleRepeatMode = usePlayerStore((state) => state.cycleRepeatMode)
|
|
const setVolume = usePlayerStore((state) => state.setVolume)
|
|
const seekTo = usePlayerStore((state) => state.seekTo)
|
|
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
|
const [tab, setTab] = useState<'queue' | 'now' | 'lyrics'>('now')
|
|
const favoritesQuery = useQuery({
|
|
queryKey: ['favorites'],
|
|
queryFn: fetchFavorites,
|
|
enabled: !!currentTrack,
|
|
})
|
|
|
|
const lyricsQuery = useQuery({
|
|
queryKey: ['lrclib', currentTrack?.id],
|
|
enabled: !!currentTrack,
|
|
queryFn: async () => {
|
|
const url = new URL('https://lrclib.net/api/get')
|
|
url.searchParams.set('track_name', currentTrack?.title ?? '')
|
|
url.searchParams.set('artist_name', currentTrack?.artistName ?? '')
|
|
url.searchParams.set('album_name', currentTrack?.albumTitle ?? '')
|
|
const response = await fetch(url.toString())
|
|
if (!response.ok) {
|
|
throw new Error('lyrics not found')
|
|
}
|
|
return response.json() as Promise<{ syncedLyrics?: string; plainLyrics?: string }>
|
|
},
|
|
})
|
|
|
|
const parsedLyrics = useMemo(() => parseLyrics(lyricsQuery.data?.syncedLyrics ?? lyricsQuery.data?.plainLyrics ?? ''), [lyricsQuery.data])
|
|
const favoriteTrackIds = useMemo(() => new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id)), [favoritesQuery.data])
|
|
const activeLine = useMemo(() => {
|
|
if (parsedLyrics.length === 0) {
|
|
return -1
|
|
}
|
|
for (let index = parsedLyrics.length - 1; index >= 0; index -= 1) {
|
|
if (parsedLyrics[index].time <= currentTime) {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}, [currentTime, parsedLyrics])
|
|
|
|
if (!currentTrack) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-[#0a0f1b]/80 backdrop-blur-2xl">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.08),transparent_40%),linear-gradient(180deg,rgba(255,255,255,0.16),rgba(8,15,27,0.72))]" />
|
|
<div className="relative flex h-full flex-col px-10 py-12">
|
|
<div className="mx-auto grid w-full max-w-[1720px] grid-cols-3 rounded-[8px] bg-white/20 p-1 text-sm text-white/80">
|
|
<PlayerTab active={tab === 'queue'} label="Очередь" onClick={() => setTab('queue')} />
|
|
<PlayerTab active={tab === 'now'} label="Сейчас играет" onClick={() => setTab('now')} />
|
|
<PlayerTab active={tab === 'lyrics'} label="Текст" onClick={() => setTab('lyrics')} />
|
|
</div>
|
|
|
|
<div className="mt-14 min-h-0 flex-1">
|
|
{tab === 'now' ? (
|
|
<div className="grid h-full grid-cols-[550px_minmax(0,1fr)] items-center gap-6">
|
|
<div className="aspect-square overflow-hidden rounded-[18px] bg-white/10">
|
|
{currentTrack.coverArtId ? <img alt={currentTrack.title} className="h-full w-full object-cover" src={coverArtUrl(currentTrack.id)} /> : null}
|
|
</div>
|
|
<div className="self-end pb-16">
|
|
<h2 className="text-6xl font-semibold tracking-tight text-white">{currentTrack.title}</h2>
|
|
<div className="mt-4 text-2xl text-white/80">
|
|
{currentTrack.albumTitle} • {currentTrack.artistName}
|
|
</div>
|
|
<div className="mt-4 flex gap-2">
|
|
<MetaTag>Rock</MetaTag>
|
|
<MetaTag>{new Date().getFullYear()}</MetaTag>
|
|
<MetaTag>FLAC</MetaTag>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{tab === 'lyrics' ? (
|
|
<div className="mx-auto flex h-full max-w-4xl items-center justify-center">
|
|
<div className="max-h-full space-y-5 overflow-auto text-center">
|
|
{parsedLyrics.length > 0 ? (
|
|
parsedLyrics.map((line, index) => (
|
|
<div
|
|
key={`${line.time}-${index}`}
|
|
className={[
|
|
'text-5xl font-semibold transition',
|
|
index === activeLine ? 'text-white' : 'text-white/35',
|
|
].join(' ')}
|
|
>
|
|
{line.text}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-2xl text-white/60">
|
|
{lyricsQuery.isLoading ? 'Загружаю текст...' : 'Текст песни не найден в LRCLIB'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{tab === 'queue' ? (
|
|
<div className="mx-auto max-w-5xl space-y-3">
|
|
{queue.map((track, index) => (
|
|
<button
|
|
key={`${track.id}-${index}`}
|
|
className={[
|
|
'flex w-full items-center gap-4 rounded-[12px] px-4 py-3 text-left',
|
|
track.id === currentTrack.id ? 'bg-white/10 text-white' : 'text-white/70',
|
|
].join(' ')}
|
|
onClick={() => playAtIndex(index)}
|
|
type="button"
|
|
>
|
|
<div className="w-8 text-right text-sm">{index + 1}</div>
|
|
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-white/10">
|
|
{track.coverArtId ? <img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} /> : null}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-lg">{track.title}</div>
|
|
<div className="truncate text-sm text-white/55">{track.artistName}</div>
|
|
</div>
|
|
<button
|
|
className="text-white/50 transition hover:text-white"
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
removeFromQueue(track.id)
|
|
}}
|
|
type="button"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<div className="flex items-center gap-4 text-white/90">
|
|
<span className="w-12 text-right text-[2rem]">{formatClock(currentTime)}</span>
|
|
<input
|
|
className="h-1.5 flex-1 accent-white"
|
|
max={duration || 0}
|
|
min={0}
|
|
onChange={(event) => seekTo(Number(event.target.value))}
|
|
step={0.1}
|
|
type="range"
|
|
value={Math.min(currentTime, duration || 0)}
|
|
/>
|
|
<span className="w-12 text-[2rem]">{formatClock(duration)}</span>
|
|
</div>
|
|
|
|
<div className="mt-8 grid grid-cols-[1fr_auto_1fr] items-center">
|
|
<div className="flex items-center gap-6 text-white/90">
|
|
<button className="transition hover:text-white" onClick={() => setFullPlayerOpen(false)} type="button">
|
|
<ChevronDown size={24} />
|
|
</button>
|
|
<button className="transition hover:text-white" type="button">
|
|
<ListMusic size={22} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-8 text-white/90">
|
|
<IconControl active={shuffle} icon={<Shuffle size={22} />} onClick={toggleShuffle} />
|
|
<IconControl icon={<Rewind size={22} />} onClick={playPrevious} />
|
|
<button className="grid h-16 w-16 place-items-center rounded-full bg-white text-[#121827]" onClick={togglePlayback} type="button">
|
|
{isPlaying ? <Pause size={28} /> : <Play size={28} className="translate-x-[2px]" />}
|
|
</button>
|
|
<IconControl icon={<SkipForward size={22} />} onClick={playNext} />
|
|
<IconControl active={repeatMode !== 'off'} icon={<Repeat2 size={22} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-6 text-white/90">
|
|
<FavoriteToggle active={favoriteTrackIds.has(currentTrack.id)} className="transition hover:text-white text-white/90" entityId={currentTrack.id} entityType="track" size={22} />
|
|
<div className="flex items-center gap-3">
|
|
<Volume2 size={22} />
|
|
<input
|
|
className="h-1.5 w-32 accent-white"
|
|
max={1}
|
|
min={0}
|
|
onChange={(event) => setVolume(Number(event.target.value))}
|
|
step={0.01}
|
|
type="range"
|
|
value={volume}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PlayerTab({
|
|
active,
|
|
label,
|
|
onClick,
|
|
}: {
|
|
active: boolean
|
|
label: string
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<button
|
|
className={[
|
|
'rounded-[6px] px-6 py-2.5 transition',
|
|
active ? 'bg-white/90 text-[#111827]' : 'hover:bg-white/10',
|
|
].join(' ')}
|
|
onClick={onClick}
|
|
type="button"
|
|
>
|
|
{label}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function MetaTag({ children }: { children: React.ReactNode }) {
|
|
return <div className="rounded-full bg-white/90 px-3 py-1 text-sm font-semibold text-[#111827]">{children}</div>
|
|
}
|
|
|
|
function IconControl({
|
|
icon,
|
|
active = false,
|
|
label,
|
|
onClick,
|
|
}: {
|
|
icon: React.ReactNode
|
|
active?: boolean
|
|
label?: string
|
|
onClick?: () => void
|
|
}) {
|
|
return (
|
|
<button className={['relative transition hover:text-white', active ? 'text-[#16bf8c]' : ''].join(' ')} onClick={onClick} type="button">
|
|
{icon}
|
|
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function formatClock(value: number) {
|
|
const minutes = Math.floor(value / 60)
|
|
const seconds = Math.floor(value % 60)
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
function parseLyrics(input: string): LyricsLine[] {
|
|
if (!input) {
|
|
return []
|
|
}
|
|
|
|
return input
|
|
.split('\n')
|
|
.map((line) => {
|
|
const match = line.match(/\[(\d{2}):(\d{2})(?:\.(\d{2}))?\](.*)/)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
const minutes = Number(match[1])
|
|
const seconds = Number(match[2])
|
|
const hundredths = Number(match[3] ?? 0)
|
|
return {
|
|
time: minutes * 60 + seconds + hundredths / 100,
|
|
text: match[4].trim(),
|
|
}
|
|
})
|
|
.filter((line): line is LyricsLine => !!line && !!line.text)
|
|
}
|