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[];
|
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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'Перемешать очередь',
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user