diff --git a/src/api/endpoints/lyrics.ts b/src/api/endpoints/lyrics.ts new file mode 100644 index 0000000..23fe9b6 --- /dev/null +++ b/src/api/endpoints/lyrics.ts @@ -0,0 +1,49 @@ +import { api } from '../index'; +import type { Lyrics } from '../types'; + +// `GET /tracks/{id}/lyrics` — cached lyrics for the Now Playing panel (§6.7). +// A miss is a normal 200 with `status: 'not_found'`, not an error. + +interface RawLyrics { + track_id: string; + status: string; + source: string | null; + synced: string | null; + plain: string | null; + synced_available: boolean; +} + +const toLyrics = (r: RawLyrics): Lyrics => ({ + trackId: r.track_id, + status: r.status as Lyrics['status'], + source: r.source ?? undefined, + synced: r.synced ?? undefined, + plain: r.plain ?? undefined, + syncedAvailable: r.synced_available, +}); + +export const lyricsApi = api.injectEndpoints({ + endpoints: (build) => ({ + getLyrics: build.query({ + query: (trackId) => `/tracks/${trackId}/lyrics`, + transformResponse: (raw: RawLyrics) => toLyrics(raw), + }), + refetchLyrics: build.mutation({ + query: (trackId) => ({ + url: `/tracks/${trackId}/lyrics/refetch`, + method: 'POST', + }), + transformResponse: (raw: RawLyrics) => toLyrics(raw), + // Push the fresh result straight into the getLyrics cache for this track. + async onQueryStarted(trackId, { dispatch, queryFulfilled }) { + const { data } = await queryFulfilled; + dispatch( + lyricsApi.util.upsertQueryData('getLyrics', trackId, data), + ); + }, + }), + }), + overrideExisting: false, +}); + +export const { useGetLyricsQuery, useRefetchLyricsMutation } = lyricsApi; diff --git a/src/api/types.ts b/src/api/types.ts index 6e16cb3..73715ea 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -192,6 +192,17 @@ export interface DuplicateGroup { tracks: Track[]; } +/** Cached lyrics for a track (§6.7). `not_found` is a normal state, not an + * error. `synced` is raw LRC (client parses timestamps); `plain` is fallback. */ +export interface Lyrics { + trackId: string; + status: 'found' | 'not_found' | 'pending'; + source?: string; + synced?: string; + plain?: string; + syncedAvailable: boolean; +} + export interface User { id: string; username: string; diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index c3c6c3c..bbfd724 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -2,6 +2,7 @@ import { Outlet } from 'react-router'; import { Suspense } from 'react'; import { Sidebar } from './Sidebar'; import { PersistentPlayer } from '../player/PersistentPlayer'; +import { NowPlaying } from '../player/NowPlaying'; import { QueuePanel } from '../player/QueuePanel'; import { TrackInfoDrawer } from '../track/TrackInfoDrawer'; import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog'; @@ -37,6 +38,7 @@ export function AppShell() { + ); } diff --git a/src/components/player/LyricsPanel.tsx b/src/components/player/LyricsPanel.tsx new file mode 100644 index 0000000..042ea92 --- /dev/null +++ b/src/components/player/LyricsPanel.tsx @@ -0,0 +1,169 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Spinner } from '@olly/modern-sk'; +import { useGetLyricsQuery, useRefetchLyricsMutation } from '../../api/endpoints/lyrics'; +import { parseLrc, activeLineIndex } from '../../lib/lrc'; + +/** + * Lyrics tab inside Now Playing. Synced mode highlights the active LRC line by + * playback position (auto-scrolling; tap a line to seek to its timecode); plain + * mode is a static scroll. Missing lyrics is a normal state with a re-fetch. + */ +export function LyricsPanel({ + trackId, + position, + onSeek, +}: { + trackId: string; + position: number; + onSeek: (seconds: number) => void; +}) { + const { t } = useTranslation(); + const { data, isLoading, isError, refetch } = useGetLyricsQuery(trackId); + const [refetchLyrics, { isLoading: isRefetching }] = + useRefetchLyricsMutation(); + + const lines = data?.synced ? parseLrc(data.synced) : []; + + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + +

{t('common.error')}

+ +
+ ); + } + + const hasLyrics = + data?.status === 'found' && (lines.length > 0 || Boolean(data.plain)); + + if (!hasLyrics) { + return ( + +

+ {t('lyrics.none')} +

+ +
+ ); + } + + if (lines.length > 0) { + return ( + + ); + } + + // Plain fallback — no timestamps to sync against. + return ( +
+
+        {data?.plain}
+      
+
+ ); +} + +function SyncedLyrics({ + lines, + position, + onSeek, +}: { + lines: { time: number; text: string }[]; + position: number; + onSeek: (seconds: number) => void; +}) { + const activeIndex = activeLineIndex(lines, position); + const activeRef = useRef(null); + + // Keep the active line centred as playback advances. + useEffect(() => { + activeRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }, [activeIndex]); + + return ( +
+ {lines.map((line, i) => { + const active = i === activeIndex; + return ( + + ); + })} +
+ ); +} + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/player/NowPlaying.tsx b/src/components/player/NowPlaying.tsx new file mode 100644 index 0000000..ac4bcdd --- /dev/null +++ b/src/components/player/NowPlaying.tsx @@ -0,0 +1,188 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Slider, SegmentedControl, IconButton } from '@olly/modern-sk'; +import { Icon } from '../common/Icon'; +import { ArtTile } from '../common/ArtTile'; +import { LyricsPanel } from './LyricsPanel'; +import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; +import { pause, resume, setNowPlayingOpen } from '../../store/slices/player'; +import { useAudioPlayer } from '../../hooks/useAudioPlayer'; +import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry'; +import { formatDuration } from '../../lib/format'; +import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming'; + +type Tab = 'artwork' | 'lyrics'; + +/** + * Full-screen Now Playing overlay (§A1 / §A11). Expands from the persistent + * player: big artwork or a synced Lyrics tab, plus transport + seek. Mounted in + * AppShell; visibility is driven by `player.isNowPlayingOpen`. + */ +export function NowPlaying() { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { seek, playNext, playPrev } = useAudioPlayer(); + const player = useAppSelector((s) => s.player); + const queue = useAppSelector((s) => s.queue); + const token = useAppSelector((s) => s.auth.accessToken); + const [tab, setTab] = useState('artwork'); + + const currentEntry = queue.entries[queue.currentIndex]; + const current = useResolvedQueueEntry(currentEntry); + + if (!player.isNowPlayingOpen || !currentEntry || !current) return null; + + const artUrl = + getCoverUrl(currentEntry.albumArtUrl) ?? + (token && current.hasCover + ? getTrackCoverUrl(current.trackId, token, true) + : undefined); + const seedLabel = current.albumTitle ?? current.title ?? ''; + + return ( +
+ {/* Header */} +
+ dispatch(setNowPlayingOpen(false))} + aria-label={t('nowPlaying.collapse')} + > + ⌄ + +
+ setTab(v as Tab)} + items={[ + { value: 'artwork', label: t('nowPlaying.artwork') }, + { value: 'lyrics', label: t('nowPlaying.lyrics') }, + ]} + /> +
+ + {/* Content */} +
+ {tab === 'artwork' ? ( +
+ +
+
+ {current.title} +
+
+ {current.artistName} +
+
+
+ ) : ( + + )} +
+ + {/* Transport */} +
+
+ {formatDuration(player.position * 1000)} + seek(v)} + aria-label={t('player.play')} + /> + {formatDuration(player.duration * 1000)} +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/player/PersistentPlayer.tsx b/src/components/player/PersistentPlayer.tsx index c9fa768..0637eb9 100644 --- a/src/components/player/PersistentPlayer.tsx +++ b/src/components/player/PersistentPlayer.tsx @@ -9,6 +9,7 @@ import { toggleMute, setVolume, toggleQueue, + toggleNowPlaying, } from '../../store/slices/player'; import { openTrackInfo } from '../../store/slices/ui'; import { useAudioPlayer } from '../../hooks/useAudioPlayer'; @@ -145,6 +146,14 @@ export function PersistentPlayer() { > +
); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index d011454..edd36c7 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -163,6 +163,16 @@ const en = { mute: 'Mute', unmute: 'Unmute', }, + nowPlaying: { + expand: 'Now playing', + collapse: 'Collapse', + artwork: 'Artwork', + lyrics: 'Lyrics', + }, + lyrics: { + none: 'No lyrics found for this track.', + refetch: 'Look again', + }, queue: { title: 'Play queue', shuffle: 'Shuffle queue', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 1e2e0c1..1243b0d 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -164,6 +164,16 @@ const ru: Translations = { mute: 'Выключить звук', unmute: 'Включить звук', }, + nowPlaying: { + expand: 'Сейчас играет', + collapse: 'Свернуть', + artwork: 'Обложка', + lyrics: 'Текст', + }, + lyrics: { + none: 'Текст для этого трека не найден.', + refetch: 'Искать снова', + }, queue: { title: 'Очередь воспроизведения', shuffle: 'Перемешать очередь', diff --git a/src/index.tsx b/src/index.tsx index 95f17f1..5f31054 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -23,6 +23,7 @@ import './api/endpoints/storage'; import './api/endpoints/admin'; import './api/endpoints/settings'; import './api/endpoints/upload'; +import './api/endpoints/lyrics'; // Tier 3 offline: register the audio-caching service worker (no-op if the // browser/origin doesn't support it). diff --git a/src/lib/lrc.ts b/src/lib/lrc.ts new file mode 100644 index 0000000..9427c2c --- /dev/null +++ b/src/lib/lrc.ts @@ -0,0 +1,50 @@ +/** One parsed LRC line: a timestamp (seconds) and its text. */ +export interface LrcLine { + time: number; + text: string; +} + +const TAG = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g; + +/** + * Parse an LRC string into time-sorted lines. A single source line may carry + * several timestamps (repeated lyric) — each becomes its own entry. Metadata + * tags (`[ar:…]`, `[ti:…]`) and untimed lines are skipped; empty-text lines are + * kept so the active-line highlight advances through instrumental gaps. + */ +export function parseLrc(lrc: string): LrcLine[] { + const lines: LrcLine[] = []; + for (const raw of lrc.split(/\r?\n/)) { + const stamps = [...raw.matchAll(TAG)]; + if (stamps.length === 0) continue; + const text = raw.replace(/\[[^\]]*\]/g, '').trim(); + for (const m of stamps) { + const min = Number(m[1]); + const sec = Number(m[2]); + const frac = m[3] ? Number(m[3].padEnd(3, '0')) / 1000 : 0; + lines.push({ time: min * 60 + sec + frac, text }); + } + } + return lines.sort((a, b) => a.time - b.time); +} + +/** + * Index of the active line for a playback position (seconds) — the last line + * whose timestamp has passed. Returns -1 before the first line. Assumes `lines` + * is time-sorted (as `parseLrc` returns). + */ +export function activeLineIndex(lines: LrcLine[], position: number): number { + let lo = 0; + let hi = lines.length - 1; + let result = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (lines[mid].time <= position) { + result = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return result; +} diff --git a/src/store/slices/player.ts b/src/store/slices/player.ts index 5619a20..53a6862 100644 --- a/src/store/slices/player.ts +++ b/src/store/slices/player.ts @@ -8,6 +8,7 @@ export interface PlayerState { volume: number; muted: boolean; isQueueOpen: boolean; + isNowPlayingOpen: boolean; } export const playerInitialState: PlayerState = { @@ -18,6 +19,7 @@ export const playerInitialState: PlayerState = { volume: 0.78, muted: false, isQueueOpen: false, + isNowPlayingOpen: false, }; export const playerSlice = createSlice({ @@ -55,6 +57,12 @@ export const playerSlice = createSlice({ toggleQueue(state) { state.isQueueOpen = !state.isQueueOpen; }, + toggleNowPlaying(state) { + state.isNowPlayingOpen = !state.isNowPlayingOpen; + }, + setNowPlayingOpen(state, action: PayloadAction) { + state.isNowPlayingOpen = action.payload; + }, }, }); @@ -68,5 +76,7 @@ export const { setVolume, toggleMute, toggleQueue, + toggleNowPlaying, + setNowPlayingOpen, } = playerSlice.actions; export default playerSlice.reducer;