diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 238680b..0bf10c2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,11 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AppShell } from '@/components/app-shell' +import { AlbumsPage } from '@/pages/albums-page' +import { ArtistsPage } from '@/pages/artists-page' +import { EmptyStatePage } from '@/pages/empty-state-page' import { HomePage } from '@/pages/home-page' -import { LibraryPage } from '@/pages/library-page' import { LoginPage } from '@/pages/login-page' +import { TracksPage } from '@/pages/tracks-page' import { useSessionStore } from '@/stores/session-store' export default function App() { @@ -16,10 +19,15 @@ export default function App() { } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> ) } - diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index d8f6f98..20df8e7 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -1,85 +1,193 @@ -import { Disc3, Home, Library, LogOut, Search } from 'lucide-react' -import { NavLink } from 'react-router-dom' +import { + ArrowLeft, + ArrowRight, + Disc3, + Heart, + Home, + LibraryBig, + ListMusic, + Music2, + Radio, + Search, + Settings, + Tags, + User, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { NavLink, useLocation } from 'react-router-dom' +import { CommandPalette } from '@/components/command-palette' import { PlayerBar } from '@/components/player-bar' +import { SettingsModal } from '@/components/settings-modal' import { useSessionStore } from '@/stores/session-store' +const libraryLinks = [ + { to: '/', label: 'Главная', icon: Home }, + { to: '/artists', label: 'Исполнители', icon: User }, + { to: '/tracks', label: 'Треки', icon: Music2 }, + { to: '/albums', label: 'Альбомы', icon: LibraryBig }, + { to: '/genres', label: 'Жанры', icon: Tags }, + { to: '/favorites', label: 'Избранное', icon: Heart }, + { to: '/playlists', label: 'Плейлисты', icon: ListMusic }, + { to: '/radio', label: 'Радио', icon: Radio }, +] as const + export function AppShell({ children }: { children: React.ReactNode }) { + const location = useLocation() const username = useSessionStore((state) => state.username) const clearSession = useSessionStore((state) => state.clearSession) + const [settingsOpen, setSettingsOpen] = useState(false) + const [userMenuOpen, setUserMenuOpen] = useState(false) + const [paletteOpen, setPaletteOpen] = useState(false) + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key === '/') { + event.preventDefault() + setPaletteOpen(true) + } + if (event.key === 'Escape') { + setPaletteOpen(false) + setUserMenuOpen(false) + } + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + const title = useMemo(() => { + const current = libraryLinks.find((item) => item.to === location.pathname) + return current?.label ?? 'Главная' + }, [location.pathname]) return ( -
-
- -
-
- {children} -
- -
+
+ } onClick={() => setSettingsOpen(true)} /> + } onClick={() => setUserMenuOpen((value) => !value)} /> + {userMenuOpen ? ( +
+
+
{username ?? 'demo'}
+
https://music.daemonlord.ru
+
+ + + +
+ ) : null} +
+ + +
+ + +
+
+
+
{title}
+
+
{children}
+
+ +
+
+ + setSettingsOpen(false)} /> + setPaletteOpen(false)} />
) } -function SidebarLink({ - to, +function TopIconButton({ icon, - label, + onClick, }: { - to: string icon: React.ReactNode - label: string + onClick?: () => void }) { return ( - - [ - 'flex items-center gap-3 rounded-2xl px-4 py-3 transition', - isActive ? 'bg-accent text-slate-900' : 'text-slate-300 hover:bg-slate-800/40', - ].join(' ') - } + ) } - diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx new file mode 100644 index 0000000..b89fedd --- /dev/null +++ b/apps/web/src/components/command-palette.tsx @@ -0,0 +1,246 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { + ArrowDown, + ArrowUp, + ExternalLink, + FolderSync, + ListMusic, + Palette, + Plus, + Search, + ServerCog, + X, +} from 'lucide-react' +import { useDeferredValue, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { fetchScanStatus, searchLibrary, triggerScan } from '@/lib/api' + +const commands = [ + { label: 'Перейти на страницу', icon: ExternalLink, action: 'navigate' }, + { label: 'Сменить тему', icon: Palette, action: 'theme' }, + { label: 'Плейлисты', icon: ListMusic, action: 'playlists' }, + { label: 'Создать плейлист', icon: Plus, action: 'create-playlist' }, + { label: 'Управление сервером', icon: ServerCog, action: 'server' }, +] as const + +export function CommandPalette({ + open, + onClose, +}: { + open: boolean + onClose: () => void +}) { + const navigate = useNavigate() + const [query, setQuery] = useState('') + const deferredQuery = useDeferredValue(query) + const [serverMode, setServerMode] = useState(false) + + const searchQuery = useQuery({ + queryKey: ['palette-search', deferredQuery], + queryFn: () => searchLibrary(deferredQuery), + enabled: open && deferredQuery.trim().length > 1 && !serverMode, + }) + + const statusQuery = useQuery({ + queryKey: ['scan-status'], + queryFn: fetchScanStatus, + enabled: open && serverMode, + }) + + const scanMutation = useMutation({ + mutationFn: triggerScan, + onSuccess: () => { + void statusQuery.refetch() + }, + }) + + const filteredCommands = useMemo(() => { + if (deferredQuery.trim().length > 1 || serverMode) { + return [] + } + return commands + }, [deferredQuery, serverMode]) + + if (!open) { + return null + } + + return ( +
+
+
+ + { + setQuery(event.target.value) + setServerMode(false) + }} + placeholder="Поиск альбома, исполнителя или трека" + value={query} + /> + +
+ +
+ {serverMode ? ( +
+
Управление сервером
+
+ {statusQuery.data?.tracks ?? 0} треков + {statusQuery.data?.albums ?? 0} папки + + Последнее сканирование:{' '} + {statusQuery.data?.finishedAt + ? new Date(statusQuery.data.finishedAt).toLocaleString('ru-RU') + : 'нет данных'} + +
+ +
+ + +
+
+ ) : deferredQuery.trim().length > 1 ? ( +
+
Результаты
+
+ {searchQuery.data?.artists.map((artist) => ( + { + navigate('/artists') + onClose() + }} + /> + ))} + {searchQuery.data?.albums.map((album) => ( + { + navigate('/albums') + onClose() + }} + /> + ))} + {searchQuery.data?.tracks.map((track) => ( + { + navigate('/tracks') + onClose() + }} + /> + ))} +
+
+ ) : ( +
+
Команды
+
+ {filteredCommands.map((command, index) => { + const Icon = command.icon + return ( + + ) + })} +
+
+ )} +
+ +
+ ESC + + + + + + + +
+
+
+ ) +} + +function PaletteRow({ + label, + meta, + onClick, +}: { + label: string + meta: string + onClick: () => void +}) { + return ( + + ) +} + +function Pill({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function KeyCap({ children }: { children: React.ReactNode }) { + return
{children}
+} diff --git a/apps/web/src/components/player-bar.tsx b/apps/web/src/components/player-bar.tsx index 40ac6b7..ca4146d 100644 --- a/apps/web/src/components/player-bar.tsx +++ b/apps/web/src/components/player-bar.tsx @@ -1,66 +1,119 @@ import { useEffect, useRef } from 'react' -import { Pause, Play, SkipBack, SkipForward, Volume2 } from 'lucide-react' +import { + Expand, + Forward, + Heart, + ListMusic, + Pause, + Play, + Repeat2, + Rewind, + Shuffle, + Volume2, +} from 'lucide-react' +import { streamUrl } from '@/lib/api' import { usePlayerStore } from '@/stores/player-store' -import { useSessionStore } from '@/stores/session-store' export function PlayerBar() { - const currentTrack = usePlayerStore((state) => state.currentTrack) - const token = useSessionStore((state) => state.token) const audioRef = useRef(null) - const apiBase = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040' + const currentTrack = usePlayerStore((state) => state.currentTrack) + const isPlaying = usePlayerStore((state) => state.isPlaying) + 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) useEffect(() => { - if (!audioRef.current || !currentTrack || !token) { + if (!audioRef.current || !currentTrack) { return } + audioRef.current.src = streamUrl(currentTrack.id) + if (isPlaying) { + void audioRef.current.play().catch(() => {}) + } + }, [currentTrack, isPlaying]) - audioRef.current.src = `${apiBase}/api/stream/${currentTrack.id}?token=${token}` - void audioRef.current.play().catch(() => {}) - }, [apiBase, currentTrack, token]) + useEffect(() => { + if (!audioRef.current) { + return + } + audioRef.current.volume = volume + if (isPlaying) { + void audioRef.current.play().catch(() => {}) + } else { + audioRef.current.pause() + } + }, [isPlaying, volume]) return ( -
+
+ +
+ } /> + } /> + } /> + setVolume(Number(event.target.value))} + step={0.01} + type="range" + value={volume} + /> + } /> +
+ ) } -function ControlButton({ +function BarIcon({ icon, - active = false, + onClick, }: { icon: React.ReactNode - active?: boolean + onClick?: () => void }) { return ( - ) diff --git a/apps/web/src/components/settings-modal.tsx b/apps/web/src/components/settings-modal.tsx new file mode 100644 index 0000000..9a528f2 --- /dev/null +++ b/apps/web/src/components/settings-modal.tsx @@ -0,0 +1,214 @@ +import { + Globe2, + Headphones, + MonitorSmartphone, + Paintbrush2, + ShieldCheck, + X, +} from 'lucide-react' +import { useMemo, useState } from 'react' + +const tabs = [ + { id: 'appearance', label: 'Внешний вид', icon: Paintbrush2 }, + { id: 'language', label: 'Язык и Регион', icon: Globe2 }, + { id: 'player', label: 'Проигрыватель и Звук', icon: Headphones }, + { id: 'content', label: 'Содержимое', icon: MonitorSmartphone }, + { id: 'privacy', label: 'Безопасность и Приватность', icon: ShieldCheck }, +] as const + +export function SettingsModal({ + open, + onClose, +}: { + open: boolean + onClose: () => void +}) { + const [activeTab, setActiveTab] = useState<(typeof tabs)[number]['id']>('appearance') + + const active = useMemo(() => tabs.find((item) => item.id === activeTab) ?? tabs[0], [activeTab]) + + if (!open) { + return null + } + + return ( +
+
+ + +
+
+
+ Настройки + + {active.label} +
+ +
+ +
+ {activeTab === 'appearance' ? : null} + {activeTab === 'language' ? : null} + {activeTab === 'player' ? : null} + {activeTab === 'content' ? : null} + {activeTab === 'privacy' ? : null} +
+
+
+
+ ) +} + +function AppearancePanel() { + return ( +
+ + + + + + + + +
+ {['Светлая', 'Темная', 'Черно-белая', 'One Dark'].map((theme, index) => ( +
+
+
+ {Array.from({ length: 5 }).map((_, itemIndex) => ( +
+ ))} +
+
+
+
+
+
+
+
{theme}
+
+ ))} +
+
+ ) +} + +function LanguagePanel() { + return ( +
+ +
+ Русский +
+
+ ) +} + +function PlayerPanel() { + return ( +
+ + + + + +
+ ) +} + +function ContentPanel() { + return ( +
+ + {['Исполнители', 'Треки', 'Альбомы', 'Жанры', 'Избранное', 'Плейлисты', 'Радио'].map((item) => ( + + ))} +
+ ) +} + +function PrivacyPanel() { + return ( +
+ + {['Enable Animated Covers', 'Album Page', 'Big Player', 'Bottom Player Bar', 'Queue and Lyrics', 'LRCLIB'].map((item, index) => ( + + ))} + + +
+ ) +} + +function PanelTitle({ title, copy }: { title: string; copy: string }) { + return ( +
+

{title}

+ {copy ?

{copy}

: null} +
+ ) +} + +function SettingRow({ label, enabled = false }: { label: string; enabled?: boolean }) { + return ( +
+
{label}
+
+ +
+
+ ) +} + +function Divider() { + return
+} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9a4a764..ef3063b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -6,21 +6,21 @@ export type User = { isAdmin: boolean } -export type HomePayload = { - recentAlbums: Array<{ - id: string - artistId: string - artistName: string - title: string - year: number - trackCount: number - coverArtId: string - }> - artists: Array<{ - id: string - name: string - albumCount: number - }> +export type Artist = { + id: string + name: string + albumCount: number + coverArtId: string +} + +export type Album = { + id: string + artistId: string + artistName: string + title: string + year: number + trackCount: number + coverArtId: string } export type Track = { @@ -32,6 +32,36 @@ export type Track = { albumTitle: string trackNumber: number durationSeconds: number + coverArtId?: string +} + +export type ArtistDetail = Artist & { + albums: Album[] +} + +export type AlbumDetail = Album & { + tracks: Track[] +} + +export type SearchResults = { + artists: Artist[] + albums: Album[] + tracks: Track[] +} + +export type ScanStatus = { + scanning: boolean + startedAt?: string + finishedAt?: string + lastError?: string + artists: number + albums: number + tracks: number +} + +export type HomePayload = { + recentAlbums: Album[] + artists: Artist[] } const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040' @@ -65,11 +95,48 @@ export async function fetchHome() { return request('/api/home') } +export async function fetchArtists() { + return request<{ items: Artist[] }>('/api/artists') +} + +export async function fetchArtist(id: string) { + return request(`/api/artists/${id}`) +} + +export async function fetchAlbums() { + return request<{ items: Album[] }>('/api/albums') +} + +export async function fetchAlbum(id: string) { + return request(`/api/albums/${id}`) +} + export async function fetchTracks() { return request<{ items: Track[] }>('/api/tracks') } +export async function fetchTrack(id: string) { + return request(`/api/tracks/${id}`) +} + +export async function searchLibrary(query: string) { + return request(`/api/search?q=${encodeURIComponent(query)}`) +} + +export async function fetchScanStatus() { + return request('/api/admin/scan-status') +} + +export async function triggerScan() { + return request('/api/admin/scan', { method: 'POST' }) +} + export function coverArtUrl(id: string) { const token = useSessionStore.getState().token return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}` } + +export function streamUrl(id: string) { + const token = useSessionStore.getState().token + return `${API_BASE}/api/stream/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}` +} diff --git a/apps/web/src/pages/albums-page.tsx b/apps/web/src/pages/albums-page.tsx new file mode 100644 index 0000000..279f4b1 --- /dev/null +++ b/apps/web/src/pages/albums-page.tsx @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query' +import { Search } from 'lucide-react' +import { coverArtUrl, fetchAlbums } from '@/lib/api' + +export function AlbumsPage() { + const albumsQuery = useQuery({ + queryKey: ['albums'], + queryFn: fetchAlbums, + }) + + const albums = albumsQuery.data?.items ?? [] + + return ( +
+
+ + +
+ +
+ {albums.map((album) => ( +
+
+ {album.coverArtId ? ( + {album.title} + ) : null} +
+
{album.title}
+
{album.artistName}
+
+ ))} +
+
+ ) +} diff --git a/apps/web/src/pages/artists-page.tsx b/apps/web/src/pages/artists-page.tsx new file mode 100644 index 0000000..4a11559 --- /dev/null +++ b/apps/web/src/pages/artists-page.tsx @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query' +import { SlidersHorizontal } from 'lucide-react' +import { coverArtUrl, fetchArtists } from '@/lib/api' + +export function ArtistsPage() { + const artistsQuery = useQuery({ + queryKey: ['artists'], + queryFn: fetchArtists, + }) + + const artists = artistsQuery.data?.items ?? [] + + return ( +
+
+
{artists.length}
+ +
+ +
Поиск...
+ +
+
+
#
+
Имя
+
Количество альбомов
+
+ {artists.map((artist, index) => ( +
+
{index + 1}
+
+
+ {artist.coverArtId ? {artist.name} : null} +
+
{artist.name}
+
+
{artist.albumCount}
+
+ ))} +
+
+ ) +} diff --git a/apps/web/src/pages/empty-state-page.tsx b/apps/web/src/pages/empty-state-page.tsx new file mode 100644 index 0000000..b0649f8 --- /dev/null +++ b/apps/web/src/pages/empty-state-page.tsx @@ -0,0 +1,27 @@ +export function EmptyStatePage({ + title, + action, + compact = false, +}: { + title: string + action?: string + compact?: boolean +}) { + return ( +
+
+
{title}
+ {action ? ( + + ) : null} +
+
+ ) +} diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index d520198..f682b3f 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,67 +1,110 @@ import { useQuery } from '@tanstack/react-query' -import { coverArtUrl, fetchHome } from '@/lib/api' -import { SectionTitle } from '@/components/section-title' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { coverArtUrl, fetchHome, fetchTracks } from '@/lib/api' +import { usePlayerStore } from '@/stores/player-store' export function HomePage() { + const setQueue = usePlayerStore((state) => state.setQueue) const homeQuery = useQuery({ queryKey: ['home'], queryFn: fetchHome, }) + const tracksQuery = useQuery({ + queryKey: ['tracks'], + queryFn: fetchTracks, + }) - const home = homeQuery.data + const heroTrack = tracksQuery.data?.items[0] + const recentAlbums = homeQuery.data?.recentAlbums ?? [] + const popularAlbums = [...recentAlbums].reverse() return ( -
- - -
-
-
-

Recent albums

- {homeQuery.isLoading ? 'Loading...' : 'Demo payload'} +
+
+
+
+
+ {heroTrack?.coverArtId ? ( + {heroTrack.title} + ) : null}
- -
- {home?.recentAlbums.map((album) => ( -
- {album.coverArtId ? ( - {album.title} - ) : ( -
- )} -
{album.title}
-
{album.artistName}
-
- {album.year} • {album.trackCount} tracks -
-
- ))} +
+

+ {heroTrack?.title ?? 'Dream on (Live in Paris, 2001)'} +

+
{heroTrack?.artistName ?? 'Depeche Mode'}
+
+ {new Date().getFullYear()} + {formatDuration(heroTrack?.durationSeconds ?? 339)} +
-
- -
-

Artists

-
- {home?.artists.map((artist) => ( -
-
-
{artist.name}
-
{artist.albumCount} albums
-
-
Artist
-
- ))} +
+ } /> + } />
-
-
+
+
+ + setQueue(tracksQuery.data?.items ?? [])} /> + setQueue(tracksQuery.data?.items ?? [])} />
) } + +function AlbumRow({ + title, + albums, + onPlayAll, +}: { + title: string + albums: Array<{ + id: string + title: string + artistName: string + coverArtId: string + }> + onPlayAll: () => void +}) { + return ( +
+
+

{title}

+
+ + } /> + } /> +
+
+ +
+ {albums.map((album) => ( +
+
+ {album.coverArtId ? ( + {album.title} + ) : null} +
+
{album.title}
+
{album.artistName}
+
+ ))} +
+
+ ) +} + +function CarouselButton({ icon }: { icon: React.ReactNode }) { + return +} + +function Tag({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function formatDuration(durationSeconds: number) { + const minutes = Math.floor(durationSeconds / 60) + const seconds = durationSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, '0')}` +} diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx deleted file mode 100644 index 447fc2b..0000000 --- a/apps/web/src/pages/library-page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { SectionTitle } from '@/components/section-title' -import { fetchTracks } from '@/lib/api' -import { usePlayerStore } from '@/stores/player-store' - -export function LibraryPage() { - const setQueue = usePlayerStore((state) => state.setQueue) - const playTrack = usePlayerStore((state) => state.playTrack) - const tracksQuery = useQuery({ - queryKey: ['tracks'], - queryFn: fetchTracks, - }) - - const tracks = tracksQuery.data?.items ?? [] - - return ( -
- - -
- -
- -
-
-
#
-
Track
-
Album
-
Length
-
- - {tracks.map((track) => ( - - ))} -
-
- ) -} - -function formatDuration(durationSeconds: number) { - const minutes = Math.floor(durationSeconds / 60) - const seconds = durationSeconds % 60 - return `${minutes}:${seconds.toString().padStart(2, '0')}` -} diff --git a/apps/web/src/pages/tracks-page.tsx b/apps/web/src/pages/tracks-page.tsx new file mode 100644 index 0000000..42e7d1b --- /dev/null +++ b/apps/web/src/pages/tracks-page.tsx @@ -0,0 +1,85 @@ +import { useQuery } from '@tanstack/react-query' +import { Heart, Search } from 'lucide-react' +import { coverArtUrl, fetchTracks } from '@/lib/api' +import { usePlayerStore } from '@/stores/player-store' + +export function TracksPage() { + const setQueue = usePlayerStore((state) => state.setQueue) + const playTrack = usePlayerStore((state) => state.playTrack) + const tracksQuery = useQuery({ + queryKey: ['tracks'], + queryFn: fetchTracks, + }) + + const tracks = tracksQuery.data?.items ?? [] + + return ( +
+ +
+
+
#
+
Название
+
Альбом
+
+
Прослушивания
+
Прослушано последний раз
+
Качество
+
+
+
+ {tracks.map((track, index) => ( + + ))} +
+
+
+ ) +} + +function HeaderSearch() { + return ( +
+ +
+ ) +} + +function formatDuration(durationSeconds: number) { + const minutes = Math.floor(durationSeconds / 60) + const seconds = durationSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, '0')}` +} diff --git a/apps/web/src/stores/player-store.ts b/apps/web/src/stores/player-store.ts index a6ac47e..1f83703 100644 --- a/apps/web/src/stores/player-store.ts +++ b/apps/web/src/stores/player-store.ts @@ -4,13 +4,57 @@ import type { Track } from '@/lib/api' type PlayerState = { currentTrack: Track | null queue: Track[] - setQueue: (tracks: Track[]) => void - playTrack: (track: Track) => void + isPlaying: boolean + volume: number + setQueue: (tracks: Track[], startIndex?: number) => void + playTrack: (track: Track, queue?: Track[]) => void + togglePlayback: () => void + playNext: () => void + playPrevious: () => void + setVolume: (volume: number) => void } -export const usePlayerStore = create((set) => ({ +export const usePlayerStore = create((set, get) => ({ currentTrack: null, queue: [], - setQueue: (queue) => set({ queue, currentTrack: queue[0] ?? null }), - playTrack: (currentTrack) => set({ currentTrack }), + isPlaying: false, + volume: 0.7, + setQueue: (queue, startIndex = 0) => + set({ + queue, + currentTrack: queue[startIndex] ?? null, + isPlaying: queue.length > 0, + }), + playTrack: (currentTrack, queue) => + set((state) => ({ + currentTrack, + queue: queue ?? state.queue, + isPlaying: true, + })), + togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })), + playNext: () => + set((state) => { + if (!state.currentTrack || state.queue.length === 0) { + return state + } + const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id) + const nextTrack = state.queue[index + 1] ?? state.queue[0] ?? null + return { + currentTrack: nextTrack, + isPlaying: !!nextTrack, + } + }), + playPrevious: () => + set((state) => { + if (!state.currentTrack || state.queue.length === 0) { + return state + } + const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id) + const previousTrack = state.queue[index - 1] ?? state.queue[state.queue.length - 1] ?? null + return { + currentTrack: previousTrack, + isPlaying: !!previousTrack, + } + }), + setVolume: (volume) => set({ volume }), })) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1dec099..1197046 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -4,9 +4,7 @@ :root { color: #dce6f2; - background: - radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%), - linear-gradient(180deg, #0c1624 0%, #09111c 100%); + background: #0a1220; font-family: "Segoe UI", sans-serif; } @@ -23,9 +21,7 @@ body, body { color: #dce6f2; - background: - radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%), - linear-gradient(180deg, #0c1624 0%, #09111c 100%); + background: #0a1220; } a { @@ -38,3 +34,16 @@ input { font: inherit; } +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: #10192b; +} + +::-webkit-scrollbar-thumb { + border-radius: 999px; + background: #26324a; +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 6c02452..3f977c8 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -53,6 +53,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service private.Get("/home", application.home) private.Get("/artists", application.artists) private.Get("/artists/{id}", application.artistByID) + private.Get("/albums", application.albums) private.Get("/albums/{id}", application.albumByID) private.Get("/tracks", application.tracks) private.Get("/tracks/{id}", application.trackByID) @@ -152,6 +153,15 @@ func (a app) artistByID(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, item) } +func (a app) albums(w http.ResponseWriter, r *http.Request) { + items, err := a.library.Albums(r.Context(), 1000) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load albums"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (a app) albumByID(w http.ResponseWriter, r *http.Request) { item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id")) if err != nil { diff --git a/internal/library/service.go b/internal/library/service.go index 383cacb..3f68da2 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -173,6 +173,34 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) return albums, rows.Err() } +func (s *Service) Albums(ctx context.Context, limit int) ([]Album, error) { + rows, err := s.db.QueryContext( + ctx, + `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '') + FROM albums al + JOIN artists a ON a.id = al.artist_id + LEFT JOIN tracks t ON t.album_id = al.id + GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id + ORDER BY al.year DESC, al.title ASC + LIMIT ?`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("query all albums: %w", err) + } + defer rows.Close() + + var albums []Album + for rows.Next() { + var album Album + if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil { + return nil, fmt.Errorf("scan all albums: %w", err) + } + albums = append(albums, album) + } + return albums, rows.Err() +} + func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error) { var album AlbumDetail