mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35: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:
@@ -0,0 +1,114 @@
|
||||
// lyricsplus backend powering YouLyPlus.
|
||||
// Public mirrors — no auth required. Subscription costs for Apple/Spotify/
|
||||
// Musixmatch are borne by the backend operator, not by us.
|
||||
//
|
||||
// Response semantics:
|
||||
// type: "Word" → syllabus[] contains per-word timings (karaoke-style)
|
||||
// type: "Line" → syllabus is empty/ignored; line-level sync only
|
||||
// No match / no data for the query → HTTP 503 with Cloudflare origin
|
||||
// error 1102. We treat any non-2xx as
|
||||
// a clean miss (silent caller fallback).
|
||||
//
|
||||
// All timings in the response are milliseconds.
|
||||
|
||||
const BASE_URLS: readonly string[] = [
|
||||
'https://lyricsplus.prjktla.my.id',
|
||||
'https://lyricsplus.atomix.one',
|
||||
'https://lyricsplus.binimum.org',
|
||||
'https://lyricsplus.prjktla.workers.dev',
|
||||
'https://lyricsplus-seven.vercel.app',
|
||||
];
|
||||
|
||||
export interface LyricsPlusWord {
|
||||
/** Word / syllable text, including any trailing whitespace as returned by the API. */
|
||||
text: string;
|
||||
/** Absolute start time, ms. */
|
||||
time: number;
|
||||
/** Duration, ms. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface LyricsPlusLine {
|
||||
/** Absolute line start time, ms. */
|
||||
time: number;
|
||||
/** Full line duration, ms. */
|
||||
duration: number;
|
||||
/** Line text (concatenation of syllabus text when word-sync is present). */
|
||||
text: string;
|
||||
/** Per-word timings; empty/undefined for `type: "Line"` responses. */
|
||||
syllabus?: LyricsPlusWord[];
|
||||
element?: { singer?: string };
|
||||
}
|
||||
|
||||
export interface LyricsPlusResult {
|
||||
/** `"Word"` (syllabus available) | `"Line"` (syllabus empty) | other. */
|
||||
type: 'Word' | 'Line' | string;
|
||||
lyrics: LyricsPlusLine[];
|
||||
metadata?: {
|
||||
source?: string;
|
||||
language?: string;
|
||||
songWriters?: string[];
|
||||
title?: string;
|
||||
album?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LyricsPlusQuery {
|
||||
title: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
/** Track duration in seconds — the API expects seconds. */
|
||||
durationSec?: number;
|
||||
isrc?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch lyrics from lyricsplus. Returns `null` for any miss or network
|
||||
* failure so callers can silently fall back to another provider without
|
||||
* seeing distinguishable error cases.
|
||||
*
|
||||
* Tries the primary endpoint first; on network failure (but NOT on HTTP
|
||||
* errors like 503 that indicate a clean miss) it walks the mirror list.
|
||||
*/
|
||||
export async function fetchLyricsPlus(q: LyricsPlusQuery): Promise<LyricsPlusResult | null> {
|
||||
if (!q.title.trim() || !q.artist.trim()) return null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('title', q.title);
|
||||
params.set('artist', q.artist);
|
||||
if (q.album) params.set('album', q.album);
|
||||
if (q.durationSec && q.durationSec > 0) params.set('duration', String(Math.round(q.durationSec)));
|
||||
if (q.isrc) params.set('isrc', q.isrc);
|
||||
|
||||
for (const base of BASE_URLS) {
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/lyrics/get?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
// 503 + Cloudflare error 1102 = no data. Any non-2xx = miss (no retry).
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as Partial<LyricsPlusResult> | null;
|
||||
if (!data || !Array.isArray(data.lyrics) || data.lyrics.length === 0) return null;
|
||||
|
||||
return {
|
||||
type: data.type ?? 'Line',
|
||||
lyrics: data.lyrics as LyricsPlusLine[],
|
||||
metadata: data.metadata ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
// Network / DNS failure → try next mirror.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `true` if any line has a non-empty syllabus array (i.e. karaoke-style). */
|
||||
export function hasWordSync(result: LyricsPlusResult): boolean {
|
||||
return result.lyrics.some(l => Array.isArray(l.syllabus) && l.syllabus.length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user