diff --git a/src/workspaces/officerdev/src/MusicPlayer/LyricsPane.tsx b/src/workspaces/officerdev/src/MusicPlayer/LyricsPane.tsx new file mode 100644 index 00000000..510c7d28 --- /dev/null +++ b/src/workspaces/officerdev/src/MusicPlayer/LyricsPane.tsx @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { Loader2, Music4 } from 'lucide-react'; +import type { LyricLine } from './lyrics'; +import { activeLineIndex } from './lyrics'; + +type LyricsPaneProps = { + lines: LyricLine[] | null; + synced: boolean; + loading: boolean; + positionSec: number; + onSeek: (sec: number) => void; +}; + +/** + * The lyrics sheet that expands above the play dock. Synced (.lrc) lyrics centre, highlight the current + * line, auto-scroll and seek on click; plain (.txt) lyrics left-align and scroll by hand only. + * + * The host re-renders every animation frame (it drives the scrubber), so the line list is memoised on + * the ACTIVE INDEX rather than the position — the DOM is rebuilt when the highlight moves, roughly once + * a line, not sixty times a second. + */ +export const LyricsPane = ({ lines, synced, loading, positionSec, onSeek }: LyricsPaneProps) => { + const scrollRef = useRef(null); + const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]); + + const activeIndex = useMemo( + () => (synced && lines ? activeLineIndex(lines, positionSec) : -1), + [synced, lines, positionSec], + ); + + // Keep the active line ~40% down the viewport. scrollTop rather than scrollIntoView, which would also + // scroll every ancestor and drag the whole page when the dock sits at the bottom of a scrolled screen. + useEffect(() => { + if (!synced || activeIndex < 0) return; + const box = scrollRef.current; + const el = lineRefs.current[activeIndex]; + if (!box || !el) return; + box.scrollTo({ top: Math.max(0, el.offsetTop - box.clientHeight * 0.4), behavior: 'smooth' }); + }, [activeIndex, synced]); + + const rendered = useMemo(() => { + if (!lines) return null; + return lines.map((line, i) => { + const active = synced && i === activeIndex; + const seekable = synced && line.timeSec != null; + return ( +

{ + lineRefs.current[i] = el; + }} + onClick={seekable ? () => onSeek(line.timeSec!) : undefined} + className={[ + 'py-1 text-[15px] font-semibold leading-7 transition-colors duration-200', + synced ? 'text-center' : 'text-left text-foreground/85', + // Only the colour changes on the active line — no weight or size change, so nothing reflows + // and the sheet does not jitter as the highlight moves. + active ? 'text-foreground' : synced ? 'text-muted-foreground/50' : '', + seekable ? 'cursor-pointer hover:text-foreground/80' : '', + ] + .filter(Boolean) + .join(' ')} + > + {line.text || (synced ? '♪' : ' ')} +

+ ); + }); + }, [lines, synced, activeIndex, onSeek]); + + const empty = !loading && (!lines || !lines.length); + + return ( +
+ {loading && ( +
+ +
+ )} + {empty && ( +
+ +

No lyrics for this track.

+
+ )} + {!loading &&
{rendered}
} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx index 7ebb5eb2..d42f8f65 100644 --- a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx +++ b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; -import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2 } from 'lucide-react'; +import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react'; import { SeekBar } from '../apps/FileViewer/renderers/SeekBar'; import { MusicHeart } from '../apps/Music/MusicHeart'; import { @@ -15,6 +15,8 @@ import { } from '../apps/Music/shared'; import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer'; import { GaplessEngine, type EngineTrack } from './gapless-engine'; +import { LyricsPane } from './LyricsPane'; +import { useLyrics } from './useLyrics'; // Mounted once in the persistent DashboardLayout (outside ), so it owns the single audio engine // and the site-wide play dock — playback survives navigation between routes. @@ -45,8 +47,10 @@ export const MusicPlayerHost = () => { return Number.isFinite(v) ? v : 1; }); const [muted, setMuted] = useState(false); + const [lyricsOpen, setLyricsOpen] = useState(() => localStorage.getItem('music.lyrics') === '1'); const trackKey = current ? `${current.albumRel}/${current.file}` : ''; + const lyrics = useLyrics(current?.albumRel ?? '', current?.file ?? '', lyricsOpen, token); // Latest position/duration in refs so persist reads fresh values without re-subscribing. seekToRef holds // a pending restore offset; isRestoringRef suppresses persist during the restore load so it doesn't @@ -234,6 +238,20 @@ export const MusicPlayerHost = () => { window.addEventListener('mouseup', onUp); }; + // Clicking a synced lyric line seeks to it. Stable identity: LyricsPane memoises its line list on this + // callback, and the host re-renders every animation frame. + const seekTo = useCallback((sec: number) => { + setPosition(sec); + engineRef.current?.seek(sec); + }, []); + + const toggleLyrics = () => { + setLyricsOpen((open) => { + localStorage.setItem('music.lyrics', open ? '0' : '1'); + return !open; + }); + }; + const subtitle = current?.artist ?? current?.albumRel.split('/').slice(-2, -1)[0] ?? ''; const pct = duration ? (position / duration) * 100 : 0; @@ -254,117 +272,139 @@ export const MusicPlayerHost = () => { if (!current) return null; // In-flow bottom bar (NOT position:fixed) — it reserves its own height so the content above shrinks to - // fit and the nav dock naturally sits above it, no overlap hacks needed. + // fit and the nav dock naturally sits above it, no overlap hacks needed. The lyrics sheet is part of + // that flow too, so opening it shrinks the page rather than covering it. return ( -
- {/* cover + info — click to open this album in /music */} - + + {/* transport */} +
+ + + +
+ + {/* scrubber + times */} + + {fmt(position)} + +
+
-
-

{current.title ?? current.file}

-

{subtitle}

+ + {fmt(duration)} + + + {/* volume */} +
+ +
- - {/* transport */} -
- - -
- {/* scrubber + times */} - - {fmt(position)} - -
- -
- - {fmt(duration)} - - {/* volume */} -
-
- - - -
); }; diff --git a/src/workspaces/officerdev/src/MusicPlayer/lyrics.test.ts b/src/workspaces/officerdev/src/MusicPlayer/lyrics.test.ts new file mode 100644 index 00000000..0ba8b206 --- /dev/null +++ b/src/workspaces/officerdev/src/MusicPlayer/lyrics.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { parseLyrics, activeLineIndex } from './lyrics'; + +describe('parseLyrics', () => { + test('plain text is not synced and keeps every line, blanks included', () => { + const { synced, lines } = parseLyrics('first\n\n second \n'); + expect(synced).toBe(false); + expect(lines.map((l) => l.text)).toEqual(['first', '', 'second', '']); + expect(lines.every((l) => l.timeSec === undefined)).toBe(true); + }); + + test('lrc timestamps parse to seconds, with hundredths', () => { + const { synced, lines } = parseLyrics('[00:12.50]hello\n[01:03]world'); + expect(synced).toBe(true); + expect(lines).toEqual([ + { timeSec: 12.5, text: 'hello' }, + { timeSec: 63, text: 'world' }, + ]); + }); + + test('a single-digit fraction is tenths, not thousandths', () => { + expect(parseLyrics('[00:01.5]x').lines[0]?.timeSec).toBe(1.5); + }); + + test('metadata tags are dropped', () => { + const { lines } = parseLyrics('[ar:Artist]\n[ti:Title]\n[00:01.00]real'); + expect(lines).toEqual([{ timeSec: 1, text: 'real' }]); + }); + + test('several stamps on one line become several lines, sorted by time', () => { + const { lines } = parseLyrics('[02:00.00][00:30.00]chorus\n[01:00.00]verse'); + expect(lines).toEqual([ + { timeSec: 30, text: 'chorus' }, + { timeSec: 60, text: 'verse' }, + { timeSec: 120, text: 'chorus' }, + ]); + }); + + test('an untimed line inside a synced file survives, but blanks do not', () => { + const { lines } = parseLyrics('[00:01.00]a\n\nspoken\n'); + expect(lines.map((l) => l.text)).toEqual(['spoken', 'a']); + }); + + test('an empty timed line is kept — it is a musical rest', () => { + expect(parseLyrics('[00:10.00]').lines).toEqual([{ timeSec: 10, text: '' }]); + }); +}); + +describe('activeLineIndex', () => { + const lines = [ + { timeSec: 10, text: 'a' }, + { timeSec: 20, text: 'b' }, + { timeSec: 30, text: 'c' }, + ]; + + test('-1 before the first line', () => { + expect(activeLineIndex(lines, 0)).toBe(-1); + }); + + test('the 0.2s lookahead highlights fractionally early', () => { + expect(activeLineIndex(lines, 9.7)).toBe(-1); + expect(activeLineIndex(lines, 9.9)).toBe(0); + }); + + test('holds the last line past the end', () => { + expect(activeLineIndex(lines, 25)).toBe(1); + expect(activeLineIndex(lines, 9999)).toBe(2); + }); + + test('untimed lines never become active', () => { + expect(activeLineIndex([{ text: 'x' }, { timeSec: 5, text: 'y' }], 60)).toBe(1); + }); +}); diff --git a/src/workspaces/officerdev/src/MusicPlayer/lyrics.ts b/src/workspaces/officerdev/src/MusicPlayer/lyrics.ts new file mode 100644 index 00000000..4c178842 --- /dev/null +++ b/src/workspaces/officerdev/src/MusicPlayer/lyrics.ts @@ -0,0 +1,64 @@ +/** + * Parse lyrics text into displayable lines. `.lrc` carries `[mm:ss.xx]` timestamps (possibly several per + * line, e.g. repeated choruses) and metadata tags ([ar:], [ti:], …) which are dropped. `.txt` is plain. + * A synced result is sorted by time so the active-line lookup is a simple scan. + * + * Ported from the mobile app (packages/core/src/services/lyrics.ts) — same file format, same server, + * so the two must agree on what a line is. + */ +export type LyricLine = { timeSec?: number; text: string }; +export type ParsedLyrics = { synced: boolean; lines: LyricLine[] }; + +const TIME_RE = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g; +const META_RE = /^\[(ar|ti|al|by|offset|length|re|ve|au|la|id):/i; + +/** + * Lines are treated as synced whenever the text actually contains `[mm:ss]` timestamps — the server's + * `X-Lyrics-Format` header is not trusted (it need not survive a proxy, and embedded lyrics carrying + * timestamps should sync regardless of which file they came from). No timestamps → plain text. + */ +export function parseLyrics(text: string): ParsedLyrics { + if (!/\[\d{1,2}:\d{2}/.test(text)) { + return { synced: false, lines: text.split(/\r?\n/).map((t) => ({ text: t.trim() })) }; + } + + const out: LyricLine[] = []; + for (const rawLine of text.split(/\r?\n/)) { + if (META_RE.test(rawLine.trim())) continue; + const stamps: number[] = []; + let m: RegExpExecArray | null; + TIME_RE.lastIndex = 0; + while ((m = TIME_RE.exec(rawLine)) !== null) { + const min = Number(m[1]); + const sec = Number(m[2]); + // "[00:12.3]" is three tenths, not three milliseconds — pad right before reading as thousandths. + const frac = m[3] ? Number(`${m[3]}00`.slice(0, 3)) / 1000 : 0; + stamps.push(min * 60 + sec + frac); + } + const lyric = rawLine.replace(TIME_RE, '').trim(); + if (!stamps.length) { + if (lyric) out.push({ text: lyric }); // a plain line inside an otherwise-synced file + continue; + } + for (const t of stamps) out.push({ timeSec: t, text: lyric }); + } + + const synced = out.some((l) => l.timeSec != null); + if (synced) out.sort((a, b) => (a.timeSec ?? 0) - (b.timeSec ?? 0)); + return { synced, lines: out }; +} + +/** + * Index of the active line for a playback position (synced only); -1 before the first line. The 0.2s + * lookahead lands the highlight fractionally early, which reads as on-time — arriving late reads as lag. + */ +export function activeLineIndex(lines: LyricLine[], positionSec: number): number { + let idx = -1; + for (let i = 0; i < lines.length; i++) { + const t = lines[i]?.timeSec; + if (t == null) continue; + if (t <= positionSec + 0.2) idx = i; + else break; + } + return idx; +} diff --git a/src/workspaces/officerdev/src/MusicPlayer/useLyrics.ts b/src/workspaces/officerdev/src/MusicPlayer/useLyrics.ts new file mode 100644 index 00000000..93db9aa3 --- /dev/null +++ b/src/workspaces/officerdev/src/MusicPlayer/useLyrics.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import type { LyricLine } from './lyrics'; +import { parseLyrics } from './lyrics'; + +export type UseLyrics = { + loading: boolean; + /** null while loading, and when the track has none. */ + lines: LyricLine[] | null; + synced: boolean; +}; + +/** + * Fetch + parse the current track's lyrics. Gated on `enabled` so nothing is requested until the pane + * is actually open — the dock lives on every screen and most listening happens with it closed. + * + * Deliberately NOT gated on an index "has lyrics" flag the way the mobile app does: the web player's + * queue carries only what it needs to stream, and a 404 for a track without lyrics is cheaper than + * threading that flag through every producer of a queue. + * + * Auth goes in the query string rather than a header, matching how this component already builds its + * /stream and /cover URLs. + */ +export const useLyrics = (albumRel: string, file: string, enabled: boolean, token: string | null): UseLyrics => { + const [state, setState] = useState({ loading: false, lines: null, synced: false }); + + useEffect(() => { + if (!enabled || !albumRel || !file) { + setState({ loading: false, lines: null, synced: false }); + return; + } + const url = + `/api/music/lyrics?path=${encodeURIComponent(albumRel)}&file=${encodeURIComponent(file)}` + + (token ? `&token=${encodeURIComponent(token)}` : ''); + + const ctrl = new AbortController(); + setState({ loading: true, lines: null, synced: false }); + fetch(url, { signal: ctrl.signal }) + .then(async (res) => { + // 404 is the ordinary "this track has no lyrics" answer, not an error worth surfacing. + if (!res.ok) return setState({ loading: false, lines: null, synced: false }); + const { synced, lines } = parseLyrics(await res.text()); + setState({ loading: false, lines, synced }); + }) + .catch(() => { + if (!ctrl.signal.aborted) setState({ loading: false, lines: null, synced: false }); + }); + + return () => ctrl.abort(); + }, [albumRel, file, enabled, token]); + + return state; +};