Files
Psychotoxical-psysonic/src/api/lyricsplus.ts
T
Psychotoxical 34b4445c13 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>
2026-04-17 21:30:31 +02:00

115 lines
3.7 KiB
TypeScript

// 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);
}