feat(lyrics): YouLyPlus karaoke + fix(macos): suppress mic prompt

Two independent user-facing fixes rolled together.

── Lyrics: YouLyPlus provider (issue #172) ──
Adds word-by-word synced (karaoke) lyrics as an alternative lyrics mode.
Backed by the public lyricsplus aggregator (Apple Music / Spotify /
Musixmatch / QQ Music) — no API keys on our side, subscription costs are
borne by the backend operator. Five mirrors are tried on network failure.

Settings → Lyrics now exposes a mode radio (Standard vs YouLyPlus) plus
a static-only toggle. YouLyPlus misses silently fall back to the existing
server + LRCLIB + Netease pipeline so obscure tracks still resolve. The
drag-to-reorder source list is hidden in YouLyPlus mode since the
external aggregator manages its own source priority.

Rendering: per-word spans on each line, active word highlighted with
accent-tinted glow. Subscribed imperatively via usePlayerStore.subscribe
so 500 ms progress ticks do not re-render the whole lyrics block. The
Fullscreen Player reuses the same pattern; the 5-line rail layout is
unchanged. white-space: pre on .fs-lyric-word preserves the trailing
spaces the API embeds in each syllabus token (flex context would
otherwise collapse them).

i18n: new keys in all 8 locales (de, en, fr, nl, nb, ru, es, zh).

── macOS mic-prompt suppression ──
Ships a vendored cpal 0.15.3 at patches/cpal-0.15.3/ wired via
[patch.crates-io]. The only change is in src/host/coreaudio/macos/mod.rs:
audio_unit_from_device now unconditionally uses IOType::DefaultOutput for
output streams instead of conditionally choosing HalOutput.

AUHAL (HalOutput) is classified as input-capable by macOS TCC and
triggers the microphone-permission dialog at first launch / after each
update even for playback-only apps. Previous attempts (removing
NSMicrophoneUsageDescription, setting com.apple.security.device.audio-input
false in Entitlements.plist) did not suppress the prompt because TCC
fires at AudioUnit instantiation, not at plist level. The upstream fix
in cpal PR #1070 / cpal 0.17 only helps when the pinned device equals
the system default; always-DefaultOutput covers all cases.

Tradeoff: per-device output selection is a no-op on macOS — the stream
always follows the system default. Settings.tsx surfaces this via
audioOutputDeviceMacNotice (hides the CustomSelect on macOS) and skips
audio_list_devices + device-watcher effects there. Matches the behaviour
of Apple Music / Spotify on macOS.

Also removes the now-obsolete com.apple.security.device.audio-input
entitlement (sandbox is disabled anyway, so it was ignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-17 21:30:31 +02:00
parent e1b807d0ef
commit 34b4445c13
67 changed files with 13697 additions and 129 deletions
+92 -20
View File
@@ -9,7 +9,7 @@ import { useCachedUrl } from './CachedImage';
import { getCachedUrl } from '../utils/imageCache';
import { extractCoverColors } from '../utils/dynamicColors';
import { useTranslation } from 'react-i18next';
import { useLyrics } from '../hooks/useLyrics';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore';
@@ -29,17 +29,20 @@ function formatTime(seconds: number): string {
// activeIdx=5 → railY=-3×slotH (line 5 at slot 2)
const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) {
const { syncedLines, loading } = useLyrics(currentTrack);
const lines = syncedLines as LrcLine[] | null;
const hasSynced = lines !== null && lines.length > 0;
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
// Static-only hides the FS overlay entirely — the 5-line rail UX is inherently
// time-driven; without sync the pane view is the correct surface.
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
const lineSrc: LrcLine[] | null = useWords
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
: (syncedLines as LrcLine[] | null);
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
// Keep a ref so the zustand selector can read lines without closing over
// a changing variable — avoids re-creating the selector on every render.
const linesRef = useRef<LrcLine[]>([]);
linesRef.current = hasSynced ? lines! : [];
linesRef.current = hasSynced ? lineSrc! : [];
// Selector returns the active line INDEX — zustand only re-renders when it
// actually changes, dropping us from ~10 Hz to ~0.2 Hz re-renders.
const activeIdx = usePlayerStore(s => {
const ls = linesRef.current;
if (ls.length === 0) return -1;
@@ -49,7 +52,6 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seek = usePlayerStore(s => s.seek);
// Cache slotH — avoids forcing a layout read (window.innerHeight) on every render.
const slotH = useRef(window.innerHeight * 0.06);
useEffect(() => {
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
@@ -57,13 +59,62 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
return () => window.removeEventListener('resize', onResize);
}, []);
// Event delegation — one handler for all lyric lines instead of N closures per tick.
const handleLineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
if (!target || duration <= 0) return;
seek(parseFloat(target.dataset.time!) / duration);
}, [duration, seek]);
// Per-word DOM refs keyed by line index; imperative updates skip re-renders
// on progress ticks. Only populated when `useWords` is true.
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
useEffect(() => {
wordRefs.current = [];
prevWord.current = { line: -1, word: -1 };
}, [currentTrack?.id, useWords]);
useEffect(() => {
if (!useWords) return;
const lines = wordLines as WordLyricsLine[];
const apply = (time: number) => {
let lineIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (time >= lines[i].time) lineIdx = i;
else break;
}
let wordIdx = -1;
if (lineIdx >= 0) {
const words = lines[lineIdx].words;
for (let j = 0; j < words.length; j++) {
if (time >= words[j].time) wordIdx = j;
else break;
}
}
const prev = prevWord.current;
if (prev.line === lineIdx && prev.word === wordIdx) return;
// Clear previous line's word classes.
if (prev.line !== lineIdx && prev.line >= 0 && wordRefs.current[prev.line]) {
for (const w of wordRefs.current[prev.line]) w.className = 'fs-lyric-word';
}
if (lineIdx >= 0 && wordRefs.current[lineIdx]) {
const ws = wordRefs.current[lineIdx];
for (let j = 0; j < ws.length; j++) {
ws[j].className = j < wordIdx ? 'fs-lyric-word played'
: j === wordIdx ? 'fs-lyric-word active'
: 'fs-lyric-word';
}
}
prevWord.current = { line: lineIdx, word: wordIdx };
};
apply(usePlayerStore.getState().currentTime);
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
return unsub;
}, [useWords, wordLines]);
if (!currentTrack || loading || !hasSynced) return null;
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
@@ -75,15 +126,36 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
style={{ transform: `translateY(${railY}px)` }}
onClick={handleLineClick}
>
{lines!.map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
data-time={line.time}
>
{line.text || '\u00A0'}
</div>
))}
{useWords
? (wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
data-time={line.time}
>
{line.words.length > 0 ? line.words.map((w, j) => (
<span
key={j}
className="fs-lyric-word"
ref={el => {
if (!wordRefs.current[i]) wordRefs.current[i] = [];
if (el) wordRefs.current[i][j] = el;
}}
>
{w.text}
</span>
)) : (line.text || '\u00A0')}
</div>
))
: lineSrc!.map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
data-time={line.time}
>
{line.text || '\u00A0'}
</div>
))}
</div>
</div>
);