feat: add playlists mvp for web and subsonic

This commit is contained in:
2026-04-02 23:21:01 +03:00
parent b16f9de6c8
commit 675e173303
9 changed files with 930 additions and 15 deletions

View File

@@ -7,6 +7,8 @@ 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'
import { PlaylistDetailPage } from '@/pages/playlist-detail-page'
import { PlaylistsPage } from '@/pages/playlists-page'
import { TracksPage } from '@/pages/tracks-page'
import { useSessionStore } from '@/stores/session-store'
@@ -28,7 +30,8 @@ export default function App() {
<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="Создать плейлист" />} />
<Route path="/playlists" element={<PlaylistsPage />} />
<Route path="/playlists/:id" element={<PlaylistDetailPage />} />
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -128,7 +128,7 @@ export function CommandPalette({
label={artist.name}
meta="Исполнитель"
onClick={() => {
navigate('/artists')
navigate(`/artists/${artist.id}`)
onClose()
}}
/>
@@ -139,7 +139,7 @@ export function CommandPalette({
label={album.title}
meta={album.artistName}
onClick={() => {
navigate('/albums')
navigate(`/albums/${album.id}`)
onClose()
}}
/>
@@ -181,6 +181,11 @@ export function CommandPalette({
onClose()
return
}
if (command.action === 'create-playlist') {
navigate('/playlists')
onClose()
return
}
if (command.action === 'server') {
setServerMode(true)
setQuery('')

View File

@@ -64,6 +64,21 @@ export type HomePayload = {
artists: Artist[]
}
export type PlaylistSummary = {
id: string
name: string
comment: string
public: boolean
songCount: number
durationSeconds: number
createdAt: string
updatedAt: string
}
export type PlaylistDetail = PlaylistSummary & {
tracks: Track[]
}
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
@@ -81,6 +96,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
throw new Error(`Request failed: ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return response.json() as Promise<T>
}
@@ -131,6 +150,46 @@ export async function triggerScan() {
return request<ScanStatus>('/api/admin/scan', { method: 'POST' })
}
export async function fetchPlaylists() {
return request<{ items: PlaylistSummary[] }>('/api/playlists')
}
export async function fetchPlaylist(id: string) {
return request<PlaylistDetail>(`/api/playlists/${id}`)
}
export async function createPlaylist(input: {
name: string
comment?: string
public?: boolean
trackIds?: string[]
}) {
return request<PlaylistDetail>('/api/playlists', {
method: 'POST',
body: JSON.stringify(input),
})
}
export async function updatePlaylist(
id: string,
input: {
name?: string
comment?: string
public?: boolean
addTrackIds?: string[]
removeTrackIds?: string[]
},
) {
return request<PlaylistDetail>(`/api/playlists/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
})
}
export async function deletePlaylist(id: string) {
await request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
}
export function coverArtUrl(id: string) {
const token = useSessionStore.getState().token
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`

View File

@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Heart, Play, Save, Trash2 } from 'lucide-react'
import { useNavigate, useParams } from 'react-router-dom'
import { coverArtUrl, deletePlaylist, fetchPlaylist, updatePlaylist } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function PlaylistDetailPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const playlistQuery = useQuery({
queryKey: ['playlist', id],
queryFn: () => fetchPlaylist(id),
})
const renameMutation = useMutation({
mutationFn: (name: string) => updatePlaylist(id, { name }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['playlist', id] })
void queryClient.invalidateQueries({ queryKey: ['playlists'] })
},
})
const removeMutation = useMutation({
mutationFn: (trackId: string) => updatePlaylist(id, { removeTrackIds: [trackId] }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['playlist', id] })
void queryClient.invalidateQueries({ queryKey: ['playlists'] })
},
})
const deleteMutation = useMutation({
mutationFn: () => deletePlaylist(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['playlists'] })
navigate('/playlists')
},
})
const playlist = playlistQuery.data
if (!playlist) {
return <div className="text-slate-400">Загрузка плейлиста...</div>
}
return (
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
<div className="flex min-h-[280px] items-end gap-5 bg-[linear-gradient(180deg,rgba(96,109,135,0.75),rgba(42,53,76,0.9))] px-8 py-6">
<div className="grid h-64 w-64 place-items-center rounded-[8px] bg-[#2a3447] text-slate-100">
<div className="text-center">
<div className="text-6xl font-semibold">{playlist.songCount}</div>
<div className="mt-2 text-lg text-slate-300">треков</div>
</div>
</div>
<div>
<div className="text-2xl text-white">Плейлист</div>
<h1 className="mt-3 text-[4rem] font-semibold leading-none text-white">{playlist.name}</h1>
<div className="mt-5 flex flex-wrap items-center gap-2 text-lg text-slate-200">
<span>{playlist.songCount} треков</span>
<span></span>
<span>{formatDuration(playlist.durationSeconds)}</span>
<span></span>
<span>Обновлён {new Date(playlist.updatedAt).toLocaleString('ru-RU')}</span>
</div>
</div>
</div>
<div className="bg-[linear-gradient(180deg,rgba(53,64,91,0.38),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(playlist.tracks)}
type="button"
>
<Play size={24} className="translate-x-[2px]" />
</button>
<button
className="text-slate-300 transition hover:text-white"
onClick={() => {
const name = window.prompt('Новое название плейлиста', playlist.name)
if (name && name !== playlist.name) {
renameMutation.mutate(name)
}
}}
type="button"
>
<Save 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-red-300" onClick={() => deleteMutation.mutate()} type="button">
<Trash2 size={24} />
</button>
</div>
<div className="overflow-hidden rounded-[12px] border border-[#24314f]">
<div className="grid grid-cols-[56px_minmax(0,2fr)_100px_120px_100px_52px] 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>
{playlist.tracks.map((track, index) => (
<div
key={track.id}
className="grid grid-cols-[56px_minmax(0,2fr)_100px_120px_100px_52px] gap-4 border-t border-[#1f2940] px-5 py-4"
>
<button className="text-left text-lg text-white" onClick={() => playTrack(track, playlist.tracks)} type="button">
{index + 1}
</button>
<button className="flex min-w-0 items-center gap-3 text-left" onClick={() => playTrack(track, playlist.tracks)} type="button">
<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>
</button>
<div className="text-base text-slate-200">{formatShortDuration(track.durationSeconds)}</div>
<div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
</div>
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
<button className="text-slate-400 transition hover:text-red-300" onClick={() => removeMutation.mutate(track.id)} type="button">
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
</div>
)
}
function formatShortDuration(value: number) {
const minutes = Math.floor(value / 60)
const seconds = value % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function formatDuration(value: number) {
const minutes = Math.floor(value / 60)
const hours = Math.floor(minutes / 60)
const restMinutes = minutes % 60
return hours > 0 ? `${hours} ч ${restMinutes} мин` : `${restMinutes} мин`
}

View File

@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ListMusic, Plus, Save } from 'lucide-react'
import { Link } from 'react-router-dom'
import { createPlaylist, fetchPlaylists } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function PlaylistsPage() {
const queryClient = useQueryClient()
const queue = usePlayerStore((state) => state.queue)
const playlistsQuery = useQuery({
queryKey: ['playlists'],
queryFn: fetchPlaylists,
})
const createMutation = useMutation({
mutationFn: createPlaylist,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['playlists'] })
},
})
const playlists = playlistsQuery.data?.items ?? []
return (
<div className="space-y-8">
<div className="flex items-center justify-end gap-3">
<button
className="rounded-[10px] border border-[#24314f] bg-[#0d1628] px-5 py-3 text-base text-white"
onClick={() => {
const name = window.prompt('Название нового плейлиста', `Playlist ${playlists.length + 1}`)
if (!name) {
return
}
createMutation.mutate({ name })
}}
type="button"
>
<span className="inline-flex items-center gap-2">
<Plus size={18} />
Создать плейлист
</span>
</button>
<button
className="rounded-[10px] bg-[#16bf8c] px-5 py-3 text-base font-medium text-[#081225]"
onClick={() => {
const name = window.prompt('Сохранить текущую очередь как плейлист', 'Queue Playlist')
if (!name) {
return
}
createMutation.mutate({ name, trackIds: queue.map((track) => track.id) })
}}
type="button"
>
<span className="inline-flex items-center gap-2">
<Save size={18} />
Сохранить очередь
</span>
</button>
</div>
{playlists.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{playlists.map((playlist) => (
<Link
key={playlist.id}
className="rounded-[14px] border border-[#24314f] bg-[#121b2e] p-5 transition hover:border-[#39527e] hover:bg-[#16233b]"
to={`/playlists/${playlist.id}`}
>
<div className="flex items-start justify-between">
<div className="grid h-12 w-12 place-items-center rounded-[10px] bg-[#1e2a40] text-slate-200">
<ListMusic size={22} />
</div>
<div className="rounded-full bg-[#364157] px-3 py-1 text-xs font-semibold text-white">
{playlist.songCount} треков
</div>
</div>
<div className="mt-5 text-2xl font-semibold text-white">{playlist.name}</div>
<div className="mt-2 text-base text-slate-400">{playlist.comment || 'Без описания'}</div>
<div className="mt-5 text-sm text-slate-500">
Обновлён: {new Date(playlist.updatedAt).toLocaleString('ru-RU')}
</div>
</Link>
))}
</div>
) : (
<div className="grid min-h-[480px] place-items-center rounded-[16px] border border-dashed border-[#24314f] bg-[#121b2e]">
<div className="text-center">
<div className="text-5xl font-semibold text-white">Пока не создано ни одного плейлиста</div>
<div className="mt-4 text-lg text-slate-400">Создай пустой плейлист или сохрани текущую очередь.</div>
</div>
</div>
)}
</div>
)
}