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
+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,
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>
);