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
+142 -29
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore';
import type { LrcLine } from '../api/lrclib';
import { useLyrics } from '../hooks/useLyrics';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
@@ -9,34 +11,96 @@ interface Props {
currentTrack: Track | null;
}
/**
* Word-sync highlighting is driven imperatively via `usePlayerStore.subscribe`
* so the whole lyrics block doesn't re-render on every 500 ms progress tick.
* Active-line scroll is still React-state-driven — lines change infrequently.
*/
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const { syncedLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack);
const { syncedLines, wordLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack);
const { staticOnly } = useAuthStore(useShallow(s => ({ staticOnly: s.lyricsStaticOnly })));
const hasSynced = syncedLines !== null && syncedLines.length > 0;
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
const hasSynced = !staticOnly && !useWords && syncedLines !== null && syncedLines.length > 0;
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const prevActive = useRef(-1);
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevActive = useRef({ line: -1, word: -1 });
// Reset refs when track changes
// Reset refs when track changes.
useEffect(() => {
lineRefs.current = [];
prevActive.current = -1;
wordRefs.current = [];
prevActive.current = { line: -1, word: -1 };
}, [currentTrack?.id]);
const activeIdx = hasSynced
? (syncedLines as LrcLine[]).reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
: -1;
// Imperative tracker for line+word highlighting. Subscribes directly to the
// store to skip React render cycles for 500 ms progress ticks.
useEffect(() => {
if (activeIdx < 0 || activeIdx === prevActive.current) return;
prevActive.current = activeIdx;
lineRefs.current[activeIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [activeIdx]);
if (!useWords && !hasSynced) return;
const apply = (time: number) => {
const lines = useWords ? (wordLines as WordLyricsLine[]) : (syncedLines as LrcLine[]);
let lineIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (time >= lines[i].time) lineIdx = i;
else break;
}
let wordIdx = -1;
if (useWords && lineIdx >= 0) {
const words = (wordLines as WordLyricsLine[])[lineIdx].words;
for (let j = 0; j < words.length; j++) {
if (time >= words[j].time) wordIdx = j;
else break;
}
}
const prev = prevActive.current;
if (prev.line === lineIdx && prev.word === wordIdx) return;
// Update line classes.
if (prev.line !== lineIdx) {
if (prev.line >= 0) {
const el = lineRefs.current[prev.line];
if (el) el.className = lineClass(prev.line, lineIdx);
}
if (lineIdx >= 0) {
const el = lineRefs.current[lineIdx];
if (el) {
el.className = lineClass(lineIdx, lineIdx);
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// Reset all word classes on previous line.
if (useWords && prev.line >= 0 && wordRefs.current[prev.line]) {
for (const w of wordRefs.current[prev.line]) w.className = 'lyrics-word';
}
}
// Update word classes inside the active line.
if (useWords && lineIdx >= 0 && wordRefs.current[lineIdx]) {
const ws = wordRefs.current[lineIdx];
for (let j = 0; j < ws.length; j++) {
ws[j].className = j < wordIdx ? 'lyrics-word played'
: j === wordIdx ? 'lyrics-word active'
: 'lyrics-word';
}
}
prevActive.current = { line: lineIdx, word: wordIdx };
};
// Prime once from the current store value.
apply(usePlayerStore.getState().currentTime);
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
return unsub;
}, [useWords, hasSynced, wordLines, syncedLines]);
if (!currentTrack) {
return (
@@ -46,32 +110,61 @@ export default function LyricsPane({ currentTrack }: Props) {
);
}
const getLyricLineClass = (i: number, active: number) => {
const base = 'lyrics-line';
if (i > active) return base;
if (i < active) return `${base} completed`;
return `${base} active`;
};
const sourceLabel = source === 'server'
? t('player.lyricsSourceServer')
: source === 'lrclib'
? t('player.lyricsSourceLrclib')
: source === 'netease'
? t('player.lyricsSourceNetease')
: null;
: source === 'lyricsplus'
? t('player.lyricsSourceLyricsplus')
: null;
// Static-only + synced or words available → render line list as static text.
const renderAsStatic = staticOnly && (
(syncedLines !== null && syncedLines.length > 0) ||
(wordLines !== null && wordLines.length > 0)
);
return (
<div className="lyrics-pane">
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
{hasSynced && (
{useWords && (
<div className="lyrics-synced lyrics-word-synced">
{(wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className="lyrics-line"
onClick={() => { if (duration > 0) seek(line.time / duration); }}
style={{ cursor: 'pointer' }}
>
{line.words.length > 0 ? line.words.map((w, j) => (
<span
key={j}
className="lyrics-word"
ref={el => {
if (!wordRefs.current[i]) wordRefs.current[i] = [];
if (el) wordRefs.current[i][j] = el;
}}
>
{w.text}
</span>
)) : (line.text || '\u00A0')}
</div>
))}
</div>
)}
{hasSynced && !useWords && (
<div className="lyrics-synced">
{(syncedLines as LrcLine[]).map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={getLyricLineClass(i, activeIdx)}
className="lyrics-line"
onClick={() => { if (duration > 0) seek(line.time / duration); }}
style={{ cursor: 'pointer' }}
>
@@ -80,16 +173,36 @@ export default function LyricsPane({ currentTrack }: Props) {
))}
</div>
)}
{!hasSynced && plainLyrics && (
{renderAsStatic && (
<div className="lyrics-plain">
{((syncedLines ?? []).length > 0
? (syncedLines as LrcLine[]).map(l => l.text)
: (wordLines as WordLyricsLine[]).map(l => l.text)
).map((text, i) => (
<p key={i} className="lyrics-plain-line">{text || '\u00A0'}</p>
))}
</div>
)}
{!renderAsStatic && !useWords && !hasSynced && plainLyrics && (
<div className="lyrics-plain">
{plainLyrics.split('\n').map((line, i) => (
<p key={i} className="lyrics-plain-line">{line || '\u00A0'}</p>
))}
</div>
)}
{sourceLabel && !loading && !notFound && (
<p className="lyrics-source">{sourceLabel}</p>
)}
</div>
);
}
function lineClass(i: number, active: number): string {
const base = 'lyrics-line';
if (i > active) return base;
if (i < active) return `${base} completed`;
return `${base} active`;
}