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