From 54f6bfa676636a28134b9826e7a6ade613498055 Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 2 Apr 2026 23:00:19 +0300 Subject: [PATCH] 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. --- apps/web/src/App.tsx | 4 + apps/web/src/components/app-shell.tsx | 4 + apps/web/src/components/full-player.tsx | 256 ++++++++++++++++++++++ apps/web/src/components/player-bar.tsx | 26 ++- apps/web/src/pages/album-detail-page.tsx | 116 ++++++++++ apps/web/src/pages/albums-page.tsx | 9 +- apps/web/src/pages/artist-detail-page.tsx | 89 ++++++++ apps/web/src/pages/artists-page.tsx | 5 +- apps/web/src/pages/home-page.tsx | 9 +- apps/web/src/stores/player-store.ts | 15 ++ 10 files changed, 521 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/full-player.tsx create mode 100644 apps/web/src/pages/album-detail-page.tsx create mode 100644 apps/web/src/pages/artist-detail-page.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0bf10c2..7f00748 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 20df8e7..3e635fa 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -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 }) { setSettingsOpen(false)} /> setPaletteOpen(false)} /> + {fullPlayerOpen ? : null} ) } diff --git a/apps/web/src/components/full-player.tsx b/apps/web/src/components/full-player.tsx new file mode 100644 index 0000000..c7c1715 --- /dev/null +++ b/apps/web/src/components/full-player.tsx @@ -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 ( +
+
+
+
+ setTab('queue')} /> + setTab('now')} /> + setTab('lyrics')} /> +
+ +
+ {tab === 'now' ? ( +
+
+ {currentTrack.coverArtId ? {currentTrack.title} : null} +
+
+

{currentTrack.title}

+
+ {currentTrack.albumTitle} • {currentTrack.artistName} +
+
+ Rock + {new Date().getFullYear()} + FLAC +
+
+
+ ) : null} + + {tab === 'lyrics' ? ( +
+
+ {parsedLyrics.length > 0 ? ( + parsedLyrics.map((line, index) => ( +
+ {line.text} +
+ )) + ) : ( +
+ {lyricsQuery.isLoading ? 'Загружаю текст...' : 'Текст песни не найден в LRCLIB'} +
+ )} +
+
+ ) : null} + + {tab === 'queue' ? ( +
+ {queue.map((track, index) => ( +
+
{index + 1}
+
+ {track.coverArtId ? {track.title} : null} +
+
+
{track.title}
+
{track.artistName}
+
+
+ ))} +
+ ) : null} +
+ +
+
+ {formatClock(currentTime)} +
+
+
+ {formatClock(duration)} +
+ +
+
+ + +
+ +
+ } /> + } onClick={playPrevious} /> + + } onClick={playNext} /> + } /> +
+ +
+ } /> +
+ + setVolume(Number(event.target.value))} + step={0.01} + type="range" + value={volume} + /> +
+
+
+
+
+
+ ) +} + +function PlayerTab({ + active, + label, + onClick, +}: { + active: boolean + label: string + onClick: () => void +}) { + return ( + + ) +} + +function MetaTag({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function IconControl({ + icon, + onClick, +}: { + icon: React.ReactNode + onClick?: () => void +}) { + return ( + + ) +} + +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) +} diff --git a/apps/web/src/components/player-bar.tsx b/apps/web/src/components/player-bar.tsx index ca4146d..b203a6a 100644 --- a/apps/web/src/components/player-bar.tsx +++ b/apps/web/src/components/player-bar.tsx @@ -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 (
-
) @@ -118,3 +128,9 @@ function BarIcon({ ) } + +function formatClock(value: number) { + const minutes = Math.floor(value / 60) + const seconds = Math.floor(value % 60) + return `${minutes}:${seconds.toString().padStart(2, '0')}` +} diff --git a/apps/web/src/pages/album-detail-page.tsx b/apps/web/src/pages/album-detail-page.tsx new file mode 100644 index 0000000..749db89 --- /dev/null +++ b/apps/web/src/pages/album-detail-page.tsx @@ -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
Загрузка альбома...
+ } + + const totalDuration = album.tracks.reduce((sum, track) => sum + track.durationSeconds, 0) + + return ( +
+
+
+ {album.coverArtId ? {album.title} : null} +
+
+
Альбом
+

{album.title}

+
+ {album.artistName} + + {album.year} + + {album.trackCount} треков + + около {formatLongDuration(totalDuration)} +
+
+
+ +
+
+ + + + +
+ +
+
+
#
+
Название
+
+
Прослушивания
+
Прослушано последний раз
+
Битрейт
+
Качество
+
+
+ {album.tracks.map((track, index) => ( + + ))} +
+
+
+ ) +} + +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} мин` +} diff --git a/apps/web/src/pages/albums-page.tsx b/apps/web/src/pages/albums-page.tsx index 279f4b1..3e3f7a8 100644 --- a/apps/web/src/pages/albums-page.tsx +++ b/apps/web/src/pages/albums-page.tsx @@ -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() {
{albums.map((album) => (
-
+ {album.coverArtId ? ( {album.title} ) : null} -
-
{album.title}
+ + + {album.title} +
{album.artistName}
))} diff --git a/apps/web/src/pages/artist-detail-page.tsx b/apps/web/src/pages/artist-detail-page.tsx new file mode 100644 index 0000000..097e9ca --- /dev/null +++ b/apps/web/src/pages/artist-detail-page.tsx @@ -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
Загрузка исполнителя...
+ } + + return ( +
+
+
+ {artist.coverArtId ? {artist.name} : null} +
+
+
Исполнитель
+

{artist.name}

+
+ {artist.albumCount} альбомов + + {artist.albums.reduce((sum, album) => sum + album.trackCount, 0)} треков +
+
+
+ +
+
+ + + + +
+ +
+
+

Недавние альбомы

+
Дискография исполнителя
+
+ +
+ {artist.albums.map((album) => ( +
+
+ {album.coverArtId ? {album.title} : null} +
+
{album.title}
+
{artist.name}
+
+ ))} +
+
+ +
+
+

Похожие исполнители

+
+
+ {artist.albums.slice(0, 4).map((album) => ( +
+
+ {album.coverArtId ? {album.title} : null} +
+
{artist.name}
+
+ ))} +
+
+
+
+ ) +} diff --git a/apps/web/src/pages/artists-page.tsx b/apps/web/src/pages/artists-page.tsx index 4a11559..7ba4330 100644 --- a/apps/web/src/pages/artists-page.tsx +++ b/apps/web/src/pages/artists-page.tsx @@ -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() {
{artist.coverArtId ? {artist.name} : null}
-
{artist.name}
+ + {artist.name} +
{artist.albumCount}
diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index f682b3f..3d83204 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -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({
{albums.map((album) => (
-
+ {album.coverArtId ? ( {album.title} ) : null} -
-
{album.title}
+ + + {album.title} +
{album.artistName}
))} diff --git a/apps/web/src/stores/player-store.ts b/apps/web/src/stores/player-store.ts index 1f83703..95b2b5c 100644 --- a/apps/web/src/stores/player-store.ts +++ b/apps/web/src/stores/player-store.ts @@ -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((set, get) => ({ @@ -19,17 +25,22 @@ export const usePlayerStore = create((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((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 }), }))