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
+89 -10
View File
@@ -4,15 +4,35 @@ import { invoke } from '@tauri-apps/api/core';
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
import { fetchNeteaselyrics } from '../api/netease';
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
import { fetchLyricsPlus, hasWordSync } from '../api/lyricsplus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
import type { Track } from '../store/playerStore';
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded';
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus';
/**
* Karaoke-style word/syllable timing inside a single line.
* All times are seconds (aligned with `LrcLine.time`), converted from the
* millisecond-based lyricsplus response.
*/
export interface WordLyricsWord {
text: string;
time: number;
duration: number;
}
export interface WordLyricsLine {
time: number;
duration: number;
text: string;
words: WordLyricsWord[];
}
export interface CachedLyrics {
syncedLines: LrcLine[] | null;
wordLines: WordLyricsLine[] | null;
plainLyrics: string | null;
source: LyricsSource | null;
notFound: boolean;
@@ -40,6 +60,7 @@ export function parseStructuredLyrics(
export interface UseLyricsResult {
syncedLines: LrcLine[] | null;
wordLines: WordLyricsLine[] | null;
plainLyrics: string | null;
source: LyricsSource | null;
loading: boolean;
@@ -48,10 +69,14 @@ export interface UseLyricsResult {
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
const { lyricsSources, lyricsMode } = useAuthStore(useShallow(s => ({
lyricsSources: s.lyricsSources,
lyricsMode: s.lyricsMode,
})));
const [loading, setLoading] = useState(!cached && !!currentTrack);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
const [wordLines, setWordLines] = useState<WordLyricsLine[] | null>(cached?.wordLines ?? null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
const [source, setSource] = useState<LyricsSource | null>(cached?.source ?? null);
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
@@ -62,6 +87,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
const hit = lyricsCache.get(currentTrack.id);
if (hit) {
setSyncedLines(hit.syncedLines);
setWordLines(hit.wordLines);
setPlainLyrics(hit.plainLyrics);
setSource(hit.source);
setNotFound(hit.notFound);
@@ -71,6 +97,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
let cancelled = false;
setSyncedLines(null);
setWordLines(null);
setPlainLyrics(null);
setSource(null);
setNotFound(false);
@@ -80,6 +107,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (cancelled) return;
lyricsCache.set(currentTrack.id, entry);
setSyncedLines(entry.syncedLines);
setWordLines(entry.wordLines);
setPlainLyrics(entry.plainLyrics);
setSource(entry.source);
setNotFound(entry.notFound);
@@ -110,7 +138,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
const plain = synced ? null : (lrcString.trim() || null);
if (!synced && !plain) return false;
store({ syncedLines: synced, plainLyrics: plain, source: 'embedded', notFound: false });
store({ syncedLines: synced, wordLines: null, plainLyrics: plain, source: 'embedded', notFound: false });
return true;
} catch {
return false;
@@ -122,7 +150,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (!structured) return false;
const parsed = parseStructuredLyrics(structured);
if (!parsed.syncedLines && !parsed.plainLyrics) return false;
store({ ...parsed, source: 'server', notFound: false });
store({ ...parsed, wordLines: null, source: 'server', notFound: false });
return true;
};
@@ -137,7 +165,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (!result || (!result.syncedLyrics && !result.plainLyrics)) return false;
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
const synced = lines && lines.length > 0 ? lines : null;
store({ syncedLines: synced, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false });
store({ syncedLines: synced, wordLines: null, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false });
return true;
} catch {
return false;
@@ -152,7 +180,52 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
const lines = parseLrc(lrc).filter(l => !NETEASE_META.test(l.text));
const synced = lines.length > 0 ? lines : null;
if (!synced) return false;
store({ syncedLines: synced, plainLyrics: null, source: 'netease', notFound: false });
store({ syncedLines: synced, wordLines: null, plainLyrics: null, source: 'netease', notFound: false });
return true;
} catch {
return false;
}
};
/**
* lyricsplus (YouLyPlus). Silent miss → caller falls back to the standard
* pipeline. Only consumed when `lyricsMode === 'lyricsplus'`.
*/
const fetchLyricsPlusFn = async (): Promise<boolean> => {
try {
const result = await fetchLyricsPlus({
title: currentTrack.title,
artist: currentTrack.artist ?? '',
album: currentTrack.album ?? undefined,
durationSec: currentTrack.duration ?? undefined,
});
if (!result || result.lyrics.length === 0) return false;
const hasWords = hasWordSync(result);
const syncedLines: LrcLine[] = result.lyrics
.map(l => ({ time: l.time / 1000, text: l.text }))
.sort((a, b) => a.time - b.time);
const wordLines: WordLyricsLine[] | null = hasWords
? result.lyrics.map(l => ({
time: l.time / 1000,
duration: l.duration / 1000,
text: l.text,
words: (l.syllabus ?? []).map(w => ({
text: w.text,
time: w.time / 1000,
duration: w.duration / 1000,
})),
}))
: null;
store({
syncedLines: syncedLines.length > 0 ? syncedLines : null,
wordLines,
plainLyrics: null,
source: 'lyricsplus',
notFound: false,
});
return true;
} catch {
return false;
@@ -170,7 +243,13 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (cancelled) return;
if (await fetchEmbedded()) return;
// Try enabled sources in user-defined order.
// YouLyPlus mode: try lyricsplus first, silent fallback to standard.
if (lyricsMode === 'lyricsplus') {
if (cancelled) return;
if (await fetchLyricsPlusFn()) return;
}
// Standard pipeline — try enabled sources in user-defined order.
for (const src of lyricsSources) {
if (!src.enabled) continue;
const fn = fetchFns[src.id];
@@ -178,11 +257,11 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (cancelled) return;
if (await fn()) return;
}
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
if (!cancelled) store({ syncedLines: null, wordLines: null, plainLyrics: null, source: null, notFound: true });
})();
return () => { cancelled = true; };
}, [currentTrack?.id, lyricsSources]); // eslint-disable-line react-hooks/exhaustive-deps
}, [currentTrack?.id, lyricsSources, lyricsMode]); // eslint-disable-line react-hooks/exhaustive-deps
return { syncedLines, plainLyrics, source, loading, notFound };
return { syncedLines, wordLines, plainLyrics, source, loading, notFound };
}