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,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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user