mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
+89
-10
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user