feat: add fullscreen player and detail library pages

Introduce a fullscreen player overlay with queue, now playing, and LRCLIB lyrics tabs. Add dedicated album and artist detail pages inspired by the Aonsoku references and wire navigation from the home, artists, and albums views into those detail routes.
This commit is contained in:
2026-04-02 23:00:19 +03:00
parent 2e7283baad
commit 54f6bfa676
10 changed files with 521 additions and 12 deletions

View File

@@ -0,0 +1,256 @@
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, Heart, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Volume2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { coverArtUrl } 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 togglePlayback = usePlayerStore((state) => state.togglePlayback)
const playNext = usePlayerStore((state) => state.playNext)
const playPrevious = usePlayerStore((state) => state.playPrevious)
const setVolume = usePlayerStore((state) => state.setVolume)
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
const [tab, setTab] = useState<'queue' | 'now' | 'lyrics'>('now')
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 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) => (
<div
key={`${track.id}-${index}`}
className={[
'flex items-center gap-4 rounded-[12px] px-4 py-3',
track.id === currentTrack.id ? 'bg-white/10 text-white' : 'text-white/70',
].join(' ')}
>
<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">
<div className="truncate text-lg">{track.title}</div>
<div className="truncate text-sm text-white/55">{track.artistName}</div>
</div>
</div>
))}
</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>
<div className="h-1.5 flex-1 rounded-full bg-white/20">
<div className="h-1.5 rounded-full bg-white" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
</div>
<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 icon={<Shuffle size={22} />} />
<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 icon={<Repeat2 size={22} />} />
</div>
<div className="flex items-center justify-end gap-6 text-white/90">
<IconControl icon={<Heart 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,
onClick,
}: {
icon: React.ReactNode
onClick?: () => void
}) {
return (
<button className="transition hover:text-white" onClick={onClick} type="button">
{icon}
</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)
}