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

@@ -1,7 +1,9 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from '@/components/app-shell'
import { AlbumsPage } from '@/pages/albums-page'
import { AlbumDetailPage } from '@/pages/album-detail-page'
import { ArtistsPage } from '@/pages/artists-page'
import { ArtistDetailPage } from '@/pages/artist-detail-page'
import { EmptyStatePage } from '@/pages/empty-state-page'
import { HomePage } from '@/pages/home-page'
import { LoginPage } from '@/pages/login-page'
@@ -20,8 +22,10 @@ export default function App() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/artists" element={<ArtistsPage />} />
<Route path="/artists/:id" element={<ArtistDetailPage />} />
<Route path="/tracks" element={<TracksPage />} />
<Route path="/albums" element={<AlbumsPage />} />
<Route path="/albums/:id" element={<AlbumDetailPage />} />
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} />
<Route path="/favorites" element={<EmptyStatePage compact title="Вы еще не добавили песни в избранное!" />} />
<Route path="/playlists" element={<EmptyStatePage title="Пока не создано ни одного плейлиста" action="Создать плейлист" />} />

View File

@@ -16,8 +16,10 @@ import {
import { useEffect, useMemo, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { CommandPalette } from '@/components/command-palette'
import { FullPlayer } from '@/components/full-player'
import { PlayerBar } from '@/components/player-bar'
import { SettingsModal } from '@/components/settings-modal'
import { usePlayerStore } from '@/stores/player-store'
import { useSessionStore } from '@/stores/session-store'
const libraryLinks = [
@@ -35,6 +37,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const location = useLocation()
const username = useSessionStore((state) => state.username)
const clearSession = useSessionStore((state) => state.clearSession)
const fullPlayerOpen = usePlayerStore((state) => state.fullPlayerOpen)
const [settingsOpen, setSettingsOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
@@ -170,6 +173,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
{fullPlayerOpen ? <FullPlayer /> : null}
</div>
)
}

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)
}

View File

@@ -19,10 +19,15 @@ export function PlayerBar() {
const currentTrack = usePlayerStore((state) => state.currentTrack)
const isPlaying = usePlayerStore((state) => state.isPlaying)
const volume = usePlayerStore((state) => state.volume)
const currentTime = usePlayerStore((state) => state.currentTime)
const duration = usePlayerStore((state) => state.duration)
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 setCurrentTime = usePlayerStore((state) => state.setCurrentTime)
const setDuration = usePlayerStore((state) => state.setDuration)
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
useEffect(() => {
if (!audioRef.current || !currentTrack) {
@@ -48,7 +53,12 @@ export function PlayerBar() {
return (
<footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3">
<audio ref={audioRef} preload="metadata" />
<audio
ref={audioRef}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
preload="metadata"
/>
<div className="flex min-w-0 items-center gap-3">
<div className="grid h-[68px] w-[68px] place-items-center rounded-[8px] bg-[#1b2638]">
@@ -78,11 +88,11 @@ export function PlayerBar() {
</div>
<div className="mt-4 flex w-full max-w-xl items-center gap-3 text-xs text-slate-500">
<span>00:00</span>
<span>{formatClock(currentTime)}</span>
<div className="h-1.5 flex-1 rounded-full bg-[#1d2940]">
<div className="h-1.5 w-0 rounded-full bg-[#16bf8c]" />
<div className="h-1.5 rounded-full bg-[#16bf8c]" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
</div>
<span>00:00</span>
<span>{formatClock(duration)}</span>
</div>
</div>
@@ -99,7 +109,7 @@ export function PlayerBar() {
type="range"
value={volume}
/>
<BarIcon icon={<Expand size={17} />} />
<BarIcon icon={<Expand size={17} />} onClick={() => setFullPlayerOpen(true)} />
</div>
</footer>
)
@@ -118,3 +128,9 @@ function BarIcon({
</button>
)
}
function formatClock(value: number) {
const minutes = Math.floor(value / 60)
const seconds = Math.floor(value % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -0,0 +1,116 @@
import { useQuery } from '@tanstack/react-query'
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
import { useParams } from 'react-router-dom'
import { coverArtUrl, fetchAlbum } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function AlbumDetailPage() {
const { id = '' } = useParams()
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const albumQuery = useQuery({
queryKey: ['album', id],
queryFn: () => fetchAlbum(id),
})
const album = albumQuery.data
if (!album) {
return <div className="text-slate-400">Загрузка альбома...</div>
}
const totalDuration = album.tracks.reduce((sum, track) => sum + track.durationSeconds, 0)
return (
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
<div className="flex min-h-[300px] items-end gap-5 bg-[linear-gradient(180deg,rgba(255,255,255,0.18),rgba(61,67,80,0.18))] px-8 py-6">
<div className="h-64 w-64 overflow-hidden rounded-[8px] bg-[#2a3447]">
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</div>
<div>
<div className="text-2xl text-white">Альбом</div>
<h1 className="mt-3 text-[4rem] font-semibold leading-none text-white">{album.title}</h1>
<div className="mt-5 flex flex-wrap items-center gap-2 text-lg text-slate-200">
<span className="font-medium">{album.artistName}</span>
<span></span>
<span>{album.year}</span>
<span></span>
<span>{album.trackCount} треков</span>
<span></span>
<span>около {formatLongDuration(totalDuration)}</span>
</div>
</div>
</div>
<div className="bg-[linear-gradient(180deg,rgba(45,52,69,0.35),rgba(18,27,46,0.98))] px-8 py-6">
<div className="mb-8 flex items-center gap-6">
<button className="grid h-14 w-14 place-items-center rounded-full bg-[#16bf8c] text-[#081225]" onClick={() => setQueue(album.tracks)} type="button">
<Play size={24} className="translate-x-[2px]" />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Shuffle size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Heart size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} />
</button>
</div>
<div className="overflow-hidden rounded-[12px] border border-[#24314f]">
<div className="grid grid-cols-[56px_minmax(0,2fr)_100px_140px_180px_140px_120px_48px] gap-4 border-b border-[#24314f] px-5 py-3 text-base text-slate-300">
<div>#</div>
<div>Название</div>
<div></div>
<div>Прослушивания</div>
<div>Прослушано последний раз</div>
<div>Битрейт</div>
<div>Качество</div>
<div></div>
</div>
{album.tracks.map((track, index) => (
<button
key={track.id}
className="grid w-full grid-cols-[56px_minmax(0,2fr)_100px_140px_180px_140px_120px_48px] gap-4 border-t border-[#1f2940] px-5 py-4 text-left transition hover:bg-[#172237]"
onClick={() => playTrack(track, album.tracks)}
type="button"
>
<div className="text-lg text-white">{index + 1}</div>
<div className="flex items-center gap-3">
<div className="h-10 w-10 overflow-hidden rounded-[6px] bg-[#313d52]">
{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-[1.02rem] text-white">{track.title}</div>
<div className="truncate text-base text-slate-400">{track.artistName}</div>
</div>
</div>
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="text-base text-slate-400">1</div>
<div className="text-base text-slate-400">недавно</div>
<div className="text-base text-slate-200">935 kbps</div>
<div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
</div>
<div className="text-slate-500"></div>
</button>
))}
</div>
</div>
</div>
)
}
function formatDuration(value: number) {
const minutes = Math.floor(value / 60)
const seconds = value % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function formatLongDuration(value: number) {
const minutes = Math.floor(value / 60)
const hours = Math.floor(minutes / 60)
const restMinutes = minutes % 60
return `${hours ? `${hours} ч ` : ''}${restMinutes} мин`
}

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { coverArtUrl, fetchAlbums } from '@/lib/api'
import { Link } from 'react-router-dom'
export function AlbumsPage() {
const albumsQuery = useQuery({
@@ -24,12 +25,14 @@ export function AlbumsPage() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{albums.map((album) => (
<article key={album.id}>
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
<Link className="block aspect-square overflow-hidden rounded-[8px] bg-[#232d42]" to={`/albums/${album.id}`}>
{album.coverArtId ? (
<img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} />
) : null}
</div>
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div>
</Link>
<Link className="mt-3 line-clamp-1 block text-[1.08rem] font-semibold text-white hover:underline" to={`/albums/${album.id}`}>
{album.title}
</Link>
<div className="text-base text-slate-400">{album.artistName}</div>
</article>
))}

View File

@@ -0,0 +1,89 @@
import { useQuery } from '@tanstack/react-query'
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
import { coverArtUrl, fetchArtist } from '@/lib/api'
import { useParams } from 'react-router-dom'
export function ArtistDetailPage() {
const { id = '' } = useParams()
const artistQuery = useQuery({
queryKey: ['artist', id],
queryFn: () => fetchArtist(id),
})
const artist = artistQuery.data
if (!artist) {
return <div className="text-slate-400">Загрузка исполнителя...</div>
}
return (
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
<div className="flex min-h-[300px] items-end gap-5 bg-[linear-gradient(180deg,rgba(189,16,37,0.78),rgba(125,15,29,0.92))] px-8 py-6">
<div className="h-64 w-64 overflow-hidden rounded-[8px] bg-[#2a3447]">
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
</div>
<div>
<div className="text-2xl text-white">Исполнитель</div>
<h1 className="mt-3 text-[4rem] font-semibold leading-none text-white">{artist.name}</h1>
<div className="mt-5 flex flex-wrap items-center gap-2 text-lg text-slate-200">
<span>{artist.albumCount} альбомов</span>
<span></span>
<span>{artist.albums.reduce((sum, album) => sum + album.trackCount, 0)} треков</span>
</div>
</div>
</div>
<div className="bg-[linear-gradient(180deg,rgba(98,18,33,0.6),rgba(18,27,46,0.98))] px-8 py-6">
<div className="mb-10 flex items-center gap-6">
<button className="grid h-14 w-14 place-items-center rounded-full bg-[#16bf8c] text-[#081225]" type="button">
<Play size={24} className="translate-x-[2px]" />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Shuffle size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Heart size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} />
</button>
</div>
<section>
<div className="mb-5 flex items-center justify-between">
<h3 className="text-[2rem] font-semibold tracking-tight text-white">Недавние альбомы</h3>
<div className="text-base text-slate-300">Дискография исполнителя</div>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{artist.albums.map((album) => (
<article key={album.id}>
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</div>
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div>
<div className="text-base text-slate-400">{artist.name}</div>
</article>
))}
</div>
</section>
<section className="mt-14">
<div className="mb-5 flex items-center justify-between">
<h3 className="text-[2rem] font-semibold tracking-tight text-white">Похожие исполнители</h3>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6">
{artist.albums.slice(0, 4).map((album) => (
<article key={`similar-${album.id}`}>
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</div>
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{artist.name}</div>
</article>
))}
</div>
</section>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { SlidersHorizontal } from 'lucide-react'
import { coverArtUrl, fetchArtists } from '@/lib/api'
import { Link } from 'react-router-dom'
export function ArtistsPage() {
const artistsQuery = useQuery({
@@ -34,7 +35,9 @@ export function ArtistsPage() {
<div className="h-10 w-10 overflow-hidden rounded-[6px] bg-[#313d52]">
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
</div>
<div className="text-[1.05rem] text-white">{artist.name}</div>
<Link className="text-[1.05rem] text-white hover:underline" to={`/artists/${artist.id}`}>
{artist.name}
</Link>
</div>
<div className="text-[1.05rem] text-white">{artist.albumCount}</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { coverArtUrl, fetchHome, fetchTracks } from '@/lib/api'
import { Link } from 'react-router-dom'
import { usePlayerStore } from '@/stores/player-store'
export function HomePage() {
@@ -81,12 +82,14 @@ function AlbumRow({
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{albums.map((album) => (
<article key={album.id}>
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
<Link className="block aspect-square overflow-hidden rounded-[8px] bg-[#232d42]" to={`/albums/${album.id}`}>
{album.coverArtId ? (
<img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} />
) : null}
</div>
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div>
</Link>
<Link className="mt-3 line-clamp-1 block text-[1.08rem] font-semibold text-white hover:underline" to={`/albums/${album.id}`}>
{album.title}
</Link>
<div className="text-base text-slate-400">{album.artistName}</div>
</article>
))}

View File

@@ -6,12 +6,18 @@ type PlayerState = {
queue: Track[]
isPlaying: boolean
volume: number
currentTime: number
duration: number
fullPlayerOpen: boolean
setQueue: (tracks: Track[], startIndex?: number) => void
playTrack: (track: Track, queue?: Track[]) => void
togglePlayback: () => void
playNext: () => void
playPrevious: () => void
setVolume: (volume: number) => void
setCurrentTime: (currentTime: number) => void
setDuration: (duration: number) => void
setFullPlayerOpen: (fullPlayerOpen: boolean) => void
}
export const usePlayerStore = create<PlayerState>((set, get) => ({
@@ -19,17 +25,22 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
queue: [],
isPlaying: false,
volume: 0.7,
currentTime: 0,
duration: 0,
fullPlayerOpen: false,
setQueue: (queue, startIndex = 0) =>
set({
queue,
currentTrack: queue[startIndex] ?? null,
isPlaying: queue.length > 0,
currentTime: 0,
}),
playTrack: (currentTrack, queue) =>
set((state) => ({
currentTrack,
queue: queue ?? state.queue,
isPlaying: true,
currentTime: 0,
})),
togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
playNext: () =>
@@ -54,7 +65,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return {
currentTrack: previousTrack,
isPlaying: !!previousTrack,
currentTime: 0,
}
}),
setVolume: (volume) => set({ volume }),
setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }),
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
}))