feat(player): Now Playing overlay + synced lyrics panel (A1/A11, §6.7)

Full-screen overlay expanded from the persistent player: big artwork or a
Lyrics tab. Synced (LRC) lyrics highlight the active line by playback position,
auto-scroll, and seek on tap; plain text is the fallback; missing lyrics offers
a re-fetch. Adds lyrics endpoint + LRC parser + isNowPlayingOpen player state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 21:04:39 +03:00
parent 6efbf4b68b
commit 9d88d70be0
11 changed files with 509 additions and 0 deletions
+49
View File
@@ -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<Lyrics, string>({
query: (trackId) => `/tracks/${trackId}/lyrics`,
transformResponse: (raw: RawLyrics) => toLyrics(raw),
}),
refetchLyrics: build.mutation<Lyrics, string>({
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;
+11
View File
@@ -192,6 +192,17 @@ export interface DuplicateGroup {
tracks: Track[]; 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 { export interface User {
id: string; id: string;
username: string; username: string;
+2
View File
@@ -2,6 +2,7 @@ import { Outlet } from 'react-router';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer'; import { PersistentPlayer } from '../player/PersistentPlayer';
import { NowPlaying } from '../player/NowPlaying';
import { QueuePanel } from '../player/QueuePanel'; import { QueuePanel } from '../player/QueuePanel';
import { TrackInfoDrawer } from '../track/TrackInfoDrawer'; import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog'; import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
@@ -37,6 +38,7 @@ export function AppShell() {
</div> </div>
<AddToPlaylistDialog /> <AddToPlaylistDialog />
<PersistentPlayer /> <PersistentPlayer />
<NowPlaying />
</div> </div>
); );
} }
+169
View File
@@ -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 (
<Centered>
<Spinner />
</Centered>
);
}
if (isError) {
return (
<Centered>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.error')}</p>
<Button variant="ghost" size="sm" onClick={() => void refetch()}>
{t('common.retry')}
</Button>
</Centered>
);
}
const hasLyrics =
data?.status === 'found' && (lines.length > 0 || Boolean(data.plain));
if (!hasLyrics) {
return (
<Centered>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('lyrics.none')}
</p>
<Button
variant="ghost"
size="sm"
disabled={isRefetching}
onClick={() => void refetchLyrics(trackId)}
>
{isRefetching ? <Spinner size="sm" /> : t('lyrics.refetch')}
</Button>
</Centered>
);
}
if (lines.length > 0) {
return (
<SyncedLyrics lines={lines} position={position} onSeek={onSeek} />
);
}
// Plain fallback — no timestamps to sync against.
return (
<div style={{ padding: '1rem 0', maxWidth: 640, margin: '0 auto' }}>
<pre
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
fontSize: '1rem',
lineHeight: 1.7,
color: 'var(--color-text-1)',
margin: 0,
textAlign: 'center',
}}
>
{data?.plain}
</pre>
</div>
);
}
function SyncedLyrics({
lines,
position,
onSeek,
}: {
lines: { time: number; text: string }[];
position: number;
onSeek: (seconds: number) => void;
}) {
const activeIndex = activeLineIndex(lines, position);
const activeRef = useRef<HTMLButtonElement | null>(null);
// Keep the active line centred as playback advances.
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, [activeIndex]);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '30vh 0',
maxWidth: 640,
margin: '0 auto',
textAlign: 'center',
}}
>
{lines.map((line, i) => {
const active = i === activeIndex;
return (
<button
key={`${line.time}-${i}`}
ref={active ? activeRef : undefined}
type="button"
onClick={() => onSeek(line.time)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
font: 'inherit',
padding: '0.15rem 0.5rem',
fontSize: active ? '1.4rem' : '1.1rem',
fontWeight: active ? 700 : 500,
color: active ? 'var(--color-text-1)' : 'var(--color-text-3)',
opacity: active ? 1 : 0.6,
transition: 'all 0.2s ease',
lineHeight: 1.5,
}}
>
{line.text || '♪'}
</button>
);
})}
</div>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.75rem',
height: '100%',
minHeight: 200,
}}
>
{children}
</div>
);
}
+188
View File
@@ -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<Tab>('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 (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
background: 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '1rem 1.5rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
onClick={() => dispatch(setNowPlayingOpen(false))}
aria-label={t('nowPlaying.collapse')}
>
⌄
</IconButton>
<div style={{ flex: 1 }} />
<SegmentedControl
value={tab}
onValueChange={(v) => setTab(v as Tab)}
items={[
{ value: 'artwork', label: t('nowPlaying.artwork') },
{ value: 'lyrics', label: t('nowPlaying.lyrics') },
]}
/>
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '0 1.5rem' }}>
{tab === 'artwork' ? (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
}}
>
<ArtTile
seed={seedLabel}
size={320}
label={seedLabel}
src={artUrl}
/>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700 }}>
{current.title}
</div>
<div style={{ fontSize: '1rem', color: 'var(--color-text-2)' }}>
{current.artistName}
</div>
</div>
</div>
) : (
<LyricsPanel
trackId={current.trackId}
position={player.position}
onSeek={seek}
/>
)}
</div>
{/* Transport */}
<div
style={{
flexShrink: 0,
padding: '1rem 1.5rem 1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
maxWidth: 720,
width: '100%',
margin: '0 auto',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
<span className="pl-time">{formatDuration(player.position * 1000)}</span>
<Slider
style={{ flex: 1 }}
min={0}
max={player.duration || 1}
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
aria-label={t('player.play')}
/>
<span className="pl-time">{formatDuration(player.duration * 1000)}</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
}}
>
<button
type="button"
className="pl-tbtn"
onClick={playPrev}
title={t('player.previous')}
>
<Icon name="skip-back" fill />
</button>
<button
type="button"
className="pl-play"
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
title={player.isPlaying ? t('player.pause') : t('player.play')}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
<button
type="button"
className="pl-tbtn"
onClick={playNext}
title={t('player.next')}
>
<Icon name="skip-forward" fill />
</button>
</div>
</div>
</div>
);
}
@@ -9,6 +9,7 @@ import {
toggleMute, toggleMute,
setVolume, setVolume,
toggleQueue, toggleQueue,
toggleNowPlaying,
} from '../../store/slices/player'; } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui'; import { openTrackInfo } from '../../store/slices/ui';
import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { useAudioPlayer } from '../../hooks/useAudioPlayer';
@@ -145,6 +146,14 @@ export function PersistentPlayer() {
> >
<Icon name="queue" /> <Icon name="queue" />
</button> </button>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleNowPlaying())}
title={t('nowPlaying.expand')}
>
<Icon name="vinyl-record" />
</button>
</div> </div>
</div> </div>
); );
+10
View File
@@ -163,6 +163,16 @@ const en = {
mute: 'Mute', mute: 'Mute',
unmute: 'Unmute', unmute: 'Unmute',
}, },
nowPlaying: {
expand: 'Now playing',
collapse: 'Collapse',
artwork: 'Artwork',
lyrics: 'Lyrics',
},
lyrics: {
none: 'No lyrics found for this track.',
refetch: 'Look again',
},
queue: { queue: {
title: 'Play queue', title: 'Play queue',
shuffle: 'Shuffle queue', shuffle: 'Shuffle queue',
+10
View File
@@ -164,6 +164,16 @@ const ru: Translations = {
mute: 'Выключить звук', mute: 'Выключить звук',
unmute: 'Включить звук', unmute: 'Включить звук',
}, },
nowPlaying: {
expand: 'Сейчас играет',
collapse: 'Свернуть',
artwork: 'Обложка',
lyrics: 'Текст',
},
lyrics: {
none: 'Текст для этого трека не найден.',
refetch: 'Искать снова',
},
queue: { queue: {
title: 'Очередь воспроизведения', title: 'Очередь воспроизведения',
shuffle: 'Перемешать очередь', shuffle: 'Перемешать очередь',
+1
View File
@@ -23,6 +23,7 @@ import './api/endpoints/storage';
import './api/endpoints/admin'; import './api/endpoints/admin';
import './api/endpoints/settings'; import './api/endpoints/settings';
import './api/endpoints/upload'; import './api/endpoints/upload';
import './api/endpoints/lyrics';
// Tier 3 offline: register the audio-caching service worker (no-op if the // Tier 3 offline: register the audio-caching service worker (no-op if the
// browser/origin doesn't support it). // browser/origin doesn't support it).
+50
View File
@@ -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;
}
+10
View File
@@ -8,6 +8,7 @@ export interface PlayerState {
volume: number; volume: number;
muted: boolean; muted: boolean;
isQueueOpen: boolean; isQueueOpen: boolean;
isNowPlayingOpen: boolean;
} }
export const playerInitialState: PlayerState = { export const playerInitialState: PlayerState = {
@@ -18,6 +19,7 @@ export const playerInitialState: PlayerState = {
volume: 0.78, volume: 0.78,
muted: false, muted: false,
isQueueOpen: false, isQueueOpen: false,
isNowPlayingOpen: false,
}; };
export const playerSlice = createSlice({ export const playerSlice = createSlice({
@@ -55,6 +57,12 @@ export const playerSlice = createSlice({
toggleQueue(state) { toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen; state.isQueueOpen = !state.isQueueOpen;
}, },
toggleNowPlaying(state) {
state.isNowPlayingOpen = !state.isNowPlayingOpen;
},
setNowPlayingOpen(state, action: PayloadAction<boolean>) {
state.isNowPlayingOpen = action.payload;
},
}, },
}); });
@@ -68,5 +76,7 @@ export const {
setVolume, setVolume,
toggleMute, toggleMute,
toggleQueue, toggleQueue,
toggleNowPlaying,
setNowPlayingOpen,
} = playerSlice.actions; } = playerSlice.actions;
export default playerSlice.reducer; export default playerSlice.reducer;