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:
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<AddToPlaylistDialog />
|
||||
<PersistentPlayer />
|
||||
<NowPlaying />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
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() {
|
||||
>
|
||||
<Icon name="queue" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(toggleNowPlaying())}
|
||||
title={t('nowPlaying.expand')}
|
||||
>
|
||||
<Icon name="vinyl-record" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -164,6 +164,16 @@ const ru: Translations = {
|
||||
mute: 'Выключить звук',
|
||||
unmute: 'Включить звук',
|
||||
},
|
||||
nowPlaying: {
|
||||
expand: 'Сейчас играет',
|
||||
collapse: 'Свернуть',
|
||||
artwork: 'Обложка',
|
||||
lyrics: 'Текст',
|
||||
},
|
||||
lyrics: {
|
||||
none: 'Текст для этого трека не найден.',
|
||||
refetch: 'Искать снова',
|
||||
},
|
||||
queue: {
|
||||
title: 'Очередь воспроизведения',
|
||||
shuffle: 'Перемешать очередь',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<boolean>) {
|
||||
state.isNowPlayingOpen = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,5 +76,7 @@ export const {
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleQueue,
|
||||
toggleNowPlaying,
|
||||
setNowPlayingOpen,
|
||||
} = playerSlice.actions;
|
||||
export default playerSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user