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
+114
View File
@@ -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);
}
+92 -20
View File
@@ -9,7 +9,7 @@ import { useCachedUrl } from './CachedImage';
import { getCachedUrl } from '../utils/imageCache';
import { extractCoverColors } from '../utils/dynamicColors';
import { useTranslation } from 'react-i18next';
import { useLyrics } from '../hooks/useLyrics';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore';
@@ -29,17 +29,20 @@ function formatTime(seconds: number): string {
// activeIdx=5 → railY=-3×slotH (line 5 at slot 2)
const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) {
const { syncedLines, loading } = useLyrics(currentTrack);
const lines = syncedLines as LrcLine[] | null;
const hasSynced = lines !== null && lines.length > 0;
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
// Static-only hides the FS overlay entirely — the 5-line rail UX is inherently
// time-driven; without sync the pane view is the correct surface.
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
const lineSrc: LrcLine[] | null = useWords
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
: (syncedLines as LrcLine[] | null);
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
// Keep a ref so the zustand selector can read lines without closing over
// a changing variable — avoids re-creating the selector on every render.
const linesRef = useRef<LrcLine[]>([]);
linesRef.current = hasSynced ? lines! : [];
linesRef.current = hasSynced ? lineSrc! : [];
// Selector returns the active line INDEX — zustand only re-renders when it
// actually changes, dropping us from ~10 Hz to ~0.2 Hz re-renders.
const activeIdx = usePlayerStore(s => {
const ls = linesRef.current;
if (ls.length === 0) return -1;
@@ -49,7 +52,6 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seek = usePlayerStore(s => s.seek);
// Cache slotH — avoids forcing a layout read (window.innerHeight) on every render.
const slotH = useRef(window.innerHeight * 0.06);
useEffect(() => {
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
@@ -57,13 +59,62 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
return () => window.removeEventListener('resize', onResize);
}, []);
// Event delegation — one handler for all lyric lines instead of N closures per tick.
const handleLineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
if (!target || duration <= 0) return;
seek(parseFloat(target.dataset.time!) / duration);
}, [duration, seek]);
// Per-word DOM refs keyed by line index; imperative updates skip re-renders
// on progress ticks. Only populated when `useWords` is true.
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
useEffect(() => {
wordRefs.current = [];
prevWord.current = { line: -1, word: -1 };
}, [currentTrack?.id, useWords]);
useEffect(() => {
if (!useWords) return;
const lines = wordLines as WordLyricsLine[];
const apply = (time: number) => {
let lineIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (time >= lines[i].time) lineIdx = i;
else break;
}
let wordIdx = -1;
if (lineIdx >= 0) {
const words = lines[lineIdx].words;
for (let j = 0; j < words.length; j++) {
if (time >= words[j].time) wordIdx = j;
else break;
}
}
const prev = prevWord.current;
if (prev.line === lineIdx && prev.word === wordIdx) return;
// Clear previous line's word classes.
if (prev.line !== lineIdx && prev.line >= 0 && wordRefs.current[prev.line]) {
for (const w of wordRefs.current[prev.line]) w.className = 'fs-lyric-word';
}
if (lineIdx >= 0 && wordRefs.current[lineIdx]) {
const ws = wordRefs.current[lineIdx];
for (let j = 0; j < ws.length; j++) {
ws[j].className = j < wordIdx ? 'fs-lyric-word played'
: j === wordIdx ? 'fs-lyric-word active'
: 'fs-lyric-word';
}
}
prevWord.current = { line: lineIdx, word: wordIdx };
};
apply(usePlayerStore.getState().currentTime);
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
return unsub;
}, [useWords, wordLines]);
if (!currentTrack || loading || !hasSynced) return null;
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
@@ -75,15 +126,36 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
style={{ transform: `translateY(${railY}px)` }}
onClick={handleLineClick}
>
{lines!.map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
data-time={line.time}
>
{line.text || '\u00A0'}
</div>
))}
{useWords
? (wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
data-time={line.time}
>
{line.words.length > 0 ? line.words.map((w, j) => (
<span
key={j}
className="fs-lyric-word"
ref={el => {
if (!wordRefs.current[i]) wordRefs.current[i] = [];
if (el) wordRefs.current[i][j] = el;
}}
>
{w.text}
</span>
)) : (line.text || '\u00A0')}
</div>
))
: lineSrc!.map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
data-time={line.time}
>
{line.text || '\u00A0'}
</div>
))}
</div>
</div>
);
+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`;
}
+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 };
}
+8
View File
@@ -524,6 +524,7 @@ export const deTranslation = {
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
audioOutputDeviceNotInCurrentList: 'nicht in der aktuellen Liste',
audioOutputDeviceMacNotice: 'Auf macOS folgt die Wiedergabe aus technischen Gründen zurzeit immer dem System-Ausgabegerät. Du kannst das Ziel über Systemeinstellungen → Ton oder das Lautsprecher-Symbol in der Menüleiste wechseln. Hintergrund: CoreAudio löst beim Öffnen eines nicht-Default-Streams eine Mikrofonberechtigungsabfrage aus — wir vermeiden sie, indem wir stets den System-Default verwenden.',
hiResTitle: 'Native Hi-Res-Wiedergabe',
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.",
@@ -555,6 +556,12 @@ export const deTranslation = {
lyricsSourceServer: 'Server',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsModeStandard: 'Standard',
lyricsModeStandardDesc: 'Drei Quellen mit frei wählbarer Reihenfolge: Server-Tags, LRCLIB, Netease.',
lyricsModeLyricsplus: 'YouLyPlus (Karaoke)',
lyricsModeLyricsplusDesc: 'Wort-für-Wort-Sync aus Apple Music, Spotify, Musixmatch & QQ (Community-Backend). Fällt still zurück auf die Standard-Quellen, wenn nichts gefunden wird.',
lyricsStaticOnly: 'Nur statische Lyrics anzeigen',
lyricsStaticOnlyDesc: 'Synchronisierte Lyrics werden ohne Auto-Scroll und ohne Wort-Hervorhebung als statischer Text dargestellt.',
downloadsTitle: 'ZIP-Export & Archivierung',
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
downloadsDefault: 'Standard-Downloads-Ordner',
@@ -935,6 +942,7 @@ export const deTranslation = {
lyricsSourceServer: 'Quelle: Server',
lyricsSourceLrclib: 'Quelle: LRCLIB',
lyricsSourceNetease: 'Quelle: Netease',
lyricsSourceLyricsplus: 'Quelle: YouLyPlus',
},
songInfo: {
title: 'Song-Infos',
+8
View File
@@ -526,6 +526,7 @@ export const enTranslation = {
audioOutputDeviceOsDefaultNow: 'current system output',
audioOutputDeviceListError: 'Could not load the audio device list.',
audioOutputDeviceNotInCurrentList: 'not in current list',
audioOutputDeviceMacNotice: 'On macOS, playback currently always follows the system output device for technical reasons. Change the target via System Settings → Sound or the speaker icon in the menu bar. Background: CoreAudio triggers a microphone-permission prompt when opening a non-default stream — we avoid it by always using the system default.',
hiResTitle: 'Native Hi-Res Playback',
hiResEnabled: 'Enable native hi-res playback',
hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).",
@@ -557,6 +558,12 @@ export const enTranslation = {
lyricsSourceServer: 'Server',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsModeStandard: 'Standard',
lyricsModeStandardDesc: 'Three sources in a freely orderable list: server tags, LRCLIB, Netease.',
lyricsModeLyricsplus: 'YouLyPlus (Karaoke)',
lyricsModeLyricsplusDesc: 'Word-by-word sync sourced from Apple Music, Spotify, Musixmatch & QQ (community backend). Silently falls back to the standard sources when nothing is found.',
lyricsStaticOnly: 'Show lyrics as static text only',
lyricsStaticOnlyDesc: 'Render synced lyrics without auto-scroll and without word highlighting.',
downloadsTitle: 'ZIP Export & Archiving',
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
downloadsDefault: 'Default Downloads Folder',
@@ -937,6 +944,7 @@ export const enTranslation = {
lyricsSourceServer: 'Source: Server',
lyricsSourceLrclib: 'Source: LRCLIB',
lyricsSourceNetease: 'Source: Netease',
lyricsSourceLyricsplus: 'Source: YouLyPlus',
},
songInfo: {
title: 'Song Info',
+8
View File
@@ -527,6 +527,7 @@ export const esTranslation = {
audioOutputDeviceOsDefaultNow: 'salida actual del sistema',
audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.',
audioOutputDeviceNotInCurrentList: 'no está en la lista actual',
audioOutputDeviceMacNotice: 'En macOS, la reproducción actualmente sigue siempre al dispositivo de salida del sistema por razones técnicas. Cambia el destino mediante Ajustes del Sistema → Sonido o el icono del altavoz en la barra de menús. Motivo: CoreAudio activa una solicitud de permiso de micrófono al abrir un stream no-predeterminado — lo evitamos usando siempre la salida por defecto del sistema.',
hiResTitle: 'Reproducción Nativa Hi-Res',
hiResEnabled: 'Habilitar reproducción nativa hi-res',
hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).",
@@ -558,6 +559,12 @@ export const esTranslation = {
lyricsSourceServer: 'Servidor',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsModeStandard: 'Estándar',
lyricsModeStandardDesc: 'Tres fuentes con orden configurable: etiquetas del servidor, LRCLIB, Netease.',
lyricsModeLyricsplus: 'YouLyPlus (Karaoke)',
lyricsModeLyricsplusDesc: 'Sincronización palabra por palabra desde Apple Music, Spotify, Musixmatch y QQ (backend comunitario). Recurre silenciosamente a las fuentes estándar cuando no se encuentra nada.',
lyricsStaticOnly: 'Mostrar letras como texto estático',
lyricsStaticOnlyDesc: 'Muestra las letras sincronizadas sin desplazamiento automático ni resaltado por palabra.',
downloadsTitle: 'Exportación ZIP y Archivado',
downloadsFolderDesc: 'Carpeta de destino para álbumes que descargas como ZIP a tu computadora.',
downloadsDefault: 'Carpeta de Descargas Predeterminada',
@@ -938,6 +945,7 @@ export const esTranslation = {
lyricsSourceServer: 'Fuente: Servidor',
lyricsSourceLrclib: 'Fuente: LRCLIB',
lyricsSourceNetease: 'Fuente: Netease',
lyricsSourceLyricsplus: 'Fuente: YouLyPlus',
},
songInfo: {
title: 'Información de Canción',
+8
View File
@@ -524,6 +524,7 @@ export const frTranslation = {
audioOutputDeviceOsDefaultNow: 'sortie système actuelle',
audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.',
audioOutputDeviceNotInCurrentList: 'absent de la liste actuelle',
audioOutputDeviceMacNotice: 'Sur macOS, la lecture suit actuellement toujours la sortie audio du système pour des raisons techniques. Changez la cible via Réglages Système → Son ou l\'icône haut-parleur de la barre de menus. Contexte : CoreAudio déclenche une demande d\'autorisation du microphone à l\'ouverture d\'un flux non par défaut — nous l\'évitons en utilisant toujours la sortie système par défaut.',
hiResTitle: 'Lecture haute résolution native',
hiResEnabled: 'Activer la lecture haute résolution native',
hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).",
@@ -553,6 +554,12 @@ export const frTranslation = {
lyricsSourceServer: 'Serveur',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsModeStandard: 'Standard',
lyricsModeStandardDesc: 'Trois sources avec ordre librement configurable : tags du serveur, LRCLIB, Netease.',
lyricsModeLyricsplus: 'YouLyPlus (Karaoké)',
lyricsModeLyricsplusDesc: 'Synchronisation mot à mot depuis Apple Music, Spotify, Musixmatch et QQ (backend communautaire). Retombe silencieusement sur les sources standard si rien n\'est trouvé.',
lyricsStaticOnly: 'Afficher les paroles en texte statique uniquement',
lyricsStaticOnlyDesc: 'Affiche les paroles synchronisées sans défilement automatique ni surlignage des mots.',
downloadsTitle: 'Export ZIP & Archivage',
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
downloadsDefault: 'Dossier de téléchargement par défaut',
@@ -933,6 +940,7 @@ export const frTranslation = {
lyricsSourceServer: 'Source : Serveur',
lyricsSourceLrclib: 'Source : LRCLIB',
lyricsSourceNetease: 'Source : Netease',
lyricsSourceLyricsplus: 'Source : YouLyPlus',
},
songInfo: {
title: 'Infos du morceau',
+8
View File
@@ -525,6 +525,7 @@ export const nbTranslation = {
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste',
audioOutputDeviceMacNotice: 'På macOS følger avspillingen av tekniske årsaker alltid systemets lydutgang. Endre målet via Systeminnstillinger → Lyd eller høyttalerikonet i menylinjen. Bakgrunn: CoreAudio utløser en mikrofontillatelsesdialog når en ikke-standard strøm åpnes — vi unngår det ved alltid å bruke systemets standardutgang.',
hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
@@ -552,6 +553,12 @@ export const nbTranslation = {
lyricsSourceServer: 'Tjener',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsModeStandard: 'Standard',
lyricsModeStandardDesc: 'Tre kilder med fritt valgbar rekkefølge: server-tagger, LRCLIB, Netease.',
lyricsModeLyricsplus: 'YouLyPlus (Karaoke)',
lyricsModeLyricsplusDesc: 'Ord-for-ord-synkronisering fra Apple Music, Spotify, Musixmatch og QQ (fellesskapsbackend). Faller stille tilbake på standardkildene når ingenting finnes.',
lyricsStaticOnly: 'Vis sangtekst som statisk tekst',
lyricsStaticOnlyDesc: 'Viser synkroniserte tekster uten auto-scroll og uten ord-utheving.',
downloadsTitle: 'ZIP Eksport & Arkivering',
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
downloadsDefault: 'Standard nedlastingsmappe',
@@ -932,6 +939,7 @@ export const nbTranslation = {
lyricsSourceServer: 'Kilde: Server',
lyricsSourceLrclib: 'Kilde: LRCLIB',
lyricsSourceNetease: 'Kilde: Netease',
lyricsSourceLyricsplus: 'Kilde: YouLyPlus',
},
songInfo: {
title: 'Sanginfo',
+8
View File
@@ -523,6 +523,7 @@ export const nlTranslation = {
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
audioOutputDeviceNotInCurrentList: 'staat niet in de huidige lijst',
audioOutputDeviceMacNotice: 'Op macOS volgt de weergave om technische redenen altijd het standaard-uitvoerapparaat van het systeem. Wijzig het doel via Systeeminstellingen → Geluid of via het luidsprekerpictogram in de menubalk. Achtergrond: CoreAudio vraagt bij het openen van een niet-standaard stream om microfoontoestemming — dat vermijden we door altijd de systeemstandaard te gebruiken.',
hiResTitle: 'Natieve hi-res-weergave',
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
@@ -552,6 +553,12 @@ export const nlTranslation = {
lyricsSourceServer: 'Server',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsModeStandard: 'Standaard',
lyricsModeStandardDesc: 'Drie bronnen in een vrij te ordenen lijst: server-tags, LRCLIB, Netease.',
lyricsModeLyricsplus: 'YouLyPlus (Karaoke)',
lyricsModeLyricsplusDesc: 'Woord-voor-woord-synchronisatie uit Apple Music, Spotify, Musixmatch en QQ (community-backend). Valt stil terug op de standaardbronnen als er niets wordt gevonden.',
lyricsStaticOnly: 'Alleen statische tekst weergeven',
lyricsStaticOnlyDesc: 'Gesynchroniseerde songteksten worden zonder auto-scroll en zonder woordmarkering als statische tekst weergegeven.',
downloadsTitle: 'ZIP-export & Archivering',
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
downloadsDefault: 'Standaard downloadmap',
@@ -932,6 +939,7 @@ export const nlTranslation = {
lyricsSourceServer: 'Bron: Server',
lyricsSourceLrclib: 'Bron: LRCLIB',
lyricsSourceNetease: 'Bron: Netease',
lyricsSourceLyricsplus: 'Bron: YouLyPlus',
},
songInfo: {
title: 'Nummerinfo',
+8
View File
@@ -541,6 +541,7 @@ export const ruTranslation = {
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
audioOutputDeviceNotInCurrentList: 'нет в текущем списке',
audioOutputDeviceMacNotice: 'В macOS воспроизведение в настоящее время всегда следует за системным устройством вывода по техническим причинам. Измените цель через Системные настройки → Звук или значок динамика в строке меню. Причина: CoreAudio запрашивает разрешение на использование микрофона при открытии не-стандартного потока — мы избегаем этого, всегда используя системный вывод по умолчанию.',
hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
@@ -576,6 +577,12 @@ export const ruTranslation = {
lyricsSourceServer: 'Сервер',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsModeStandard: 'Стандарт',
lyricsModeStandardDesc: 'Три источника в свободно настраиваемом порядке: серверные теги, LRCLIB, Netease.',
lyricsModeLyricsplus: 'YouLyPlus (Караоке)',
lyricsModeLyricsplusDesc: 'Синхронизация слово-в-слово из Apple Music, Spotify, Musixmatch и QQ (общественный бэкенд). При отсутствии результата незаметно возвращается к стандартным источникам.',
lyricsStaticOnly: 'Показывать текст статично',
lyricsStaticOnlyDesc: 'Синхронизированный текст отображается без авто-прокрутки и подсветки слов.',
downloadsTitle: 'Экспорт ZIP и архивов',
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
downloadsDefault: 'Папка «Загрузки» по умолчанию',
@@ -991,6 +998,7 @@ export const ruTranslation = {
lyricsLoading: 'Загрузка текста…',
lyricsNotFound: 'Текст не найден',
lyricsSourceNetease: 'Источник: Netease',
lyricsSourceLyricsplus: 'Источник: YouLyPlus',
},
songInfo: {
title: 'О треке',
+8
View File
@@ -519,6 +519,7 @@ export const zhTranslation = {
audioOutputDeviceOsDefaultNow: '当前系统输出',
audioOutputDeviceListError: '无法加载音频设备列表。',
audioOutputDeviceNotInCurrentList: '不在当前列表中',
audioOutputDeviceMacNotice: '在 macOS 上,出于技术原因,目前播放始终跟随系统输出设备。请通过 系统设置 → 声音 或菜单栏扬声器图标切换目标设备。背景:打开非默认音频流时 CoreAudio 会触发麦克风权限提示 —— 我们通过始终使用系统默认输出来避免此提示。',
hiResTitle: '原生高清晰度播放',
hiResEnabled: '启用原生高清晰度播放',
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
@@ -548,6 +549,12 @@ export const zhTranslation = {
lyricsSourceServer: '服务器',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: '网易云音乐',
lyricsModeStandard: '标准',
lyricsModeStandardDesc: '三个可自由排序的来源:服务器标签、LRCLIB、网易云。',
lyricsModeLyricsplus: 'YouLyPlus(卡拉OK',
lyricsModeLyricsplusDesc: '来自 Apple Music、Spotify、Musixmatch 和 QQ 音乐的逐字同步歌词(社区后端)。找不到时静默回退到标准来源。',
lyricsStaticOnly: '仅以静态文本显示歌词',
lyricsStaticOnlyDesc: '同步歌词将不自动滚动、不逐字高亮,以静态文本显示。',
downloadsTitle: 'ZIP 导出与归档',
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
downloadsDefault: '默认下载文件夹',
@@ -928,6 +935,7 @@ export const zhTranslation = {
lyricsSourceServer: '来源:服务器',
lyricsSourceLrclib: '来源:LRCLIB',
lyricsSourceNetease: '来源:网易云',
lyricsSourceLyricsplus: '来源:YouLyPlus',
},
songInfo: {
title: '歌曲信息',
+146 -62
View File
@@ -23,7 +23,7 @@ import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig } from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX } from '../utils/platform';
import { IS_LINUX, IS_MACOS } from '../utils/platform';
import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
@@ -456,14 +456,16 @@ export default function Settings() {
}, [t]);
// Load available audio output devices when Audio tab opens.
// Skipped on macOS — the stream is pinned to the system default (see
// audioOutputDeviceMacNotice) so there is no picker to populate.
useEffect(() => {
if (activeTab !== 'audio') return;
if (activeTab !== 'audio' || IS_MACOS) return;
refreshAudioDevices();
}, [activeTab, refreshAudioDevices]);
// Keep device list + "current system output" mark in sync when the backend reopens the stream.
useEffect(() => {
if (activeTab !== 'audio') return;
if (activeTab !== 'audio' || IS_MACOS) return;
let cancelled = false;
const unlisteners: Array<() => void> = [];
(async () => {
@@ -693,41 +695,49 @@ export default function Settings() {
<h2>{t('settings.audioOutputDevice')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.audioOutputDeviceDesc')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<CustomSelect
style={{ flex: 1 }}
value={auth.audioOutputDevice ?? ''}
disabled={deviceSwitching || devicesLoading}
onChange={async (val) => {
const device = val || null;
setDeviceSwitching(true);
try {
await invoke('audio_set_device', { deviceName: device });
auth.setAudioOutputDevice(device);
} catch { /* device open failed — don't persist */ }
setDeviceSwitching(false);
}}
options={buildAudioDeviceSelectOptions(
audioDevices,
t('settings.audioOutputDeviceDefault'),
osDefaultAudioDeviceId,
t('settings.audioOutputDeviceOsDefaultNow'),
auth.audioOutputDevice,
t('settings.audioOutputDeviceNotInCurrentList'),
)}
/>
<button
className="icon-btn"
onClick={() => refreshAudioDevices()}
disabled={devicesLoading || deviceSwitching}
data-tooltip={t('settings.audioOutputDeviceRefresh')}
>
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
</button>
</div>
{IS_MACOS ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
{t('settings.audioOutputDeviceMacNotice')}
</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.audioOutputDeviceDesc')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<CustomSelect
style={{ flex: 1 }}
value={auth.audioOutputDevice ?? ''}
disabled={deviceSwitching || devicesLoading}
onChange={async (val) => {
const device = val || null;
setDeviceSwitching(true);
try {
await invoke('audio_set_device', { deviceName: device });
auth.setAudioOutputDevice(device);
} catch { /* device open failed — don't persist */ }
setDeviceSwitching(false);
}}
options={buildAudioDeviceSelectOptions(
audioDevices,
t('settings.audioOutputDeviceDefault'),
osDefaultAudioDeviceId,
t('settings.audioOutputDeviceOsDefaultNow'),
auth.audioOutputDevice,
t('settings.audioOutputDeviceNotInCurrentList'),
)}
/>
<button
className="icon-btn"
onClick={() => refreshAudioDevices()}
disabled={devicesLoading || deviceSwitching}
data-tooltip={t('settings.audioOutputDeviceRefresh')}
>
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
</button>
</div>
</>
)}
</div>
</section>
@@ -2538,6 +2548,10 @@ function LyricsSourcesCustomizer() {
const { t } = useTranslation();
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
const setLyricsSources = useAuthStore(s => s.setLyricsSources);
const lyricsMode = useAuthStore(s => s.lyricsMode);
const setLyricsMode = useAuthStore(s => s.setLyricsMode);
const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly);
const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly);
const { isDragging: isPsyDragging } = useDragDrop();
const containerRef = useRef<HTMLDivElement>(null);
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
@@ -2603,31 +2617,101 @@ function LyricsSourcesCustomizer() {
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('settings.lyricsSourcesDesc')}
</p>
<div className="settings-card" style={{ padding: '4px 0' }} ref={containerRef} onMouseMove={handleMouseMove}>
{lyricsSources.map((src, i) => {
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
return (
<div
key={src.id}
data-lyrics-idx={i}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<LyricsSourceGripHandle idx={i} label={label} />
<span style={{ flex: 1, fontSize: 14, opacity: src.enabled ? 1 : 0.45 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={src.enabled} onChange={() => toggleSource(src.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
{/* Mode switch standard three-provider pipeline vs. YouLyPlus karaoke.
YouLyPlus misses silently fall back to the standard pipeline. */}
<div className="settings-card" style={{ marginBottom: '0.75rem', padding: '0.75rem 1rem' }}>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'stretch', flexWrap: 'wrap' }}>
<label
style={{
flex: 1, minWidth: 220, cursor: 'pointer',
display: 'flex', gap: '0.6rem', alignItems: 'flex-start',
padding: '0.5rem', borderRadius: 6,
background: lyricsMode === 'standard' ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'transparent',
border: `1px solid ${lyricsMode === 'standard' ? 'var(--accent)' : 'transparent'}`,
}}
>
<input
type="radio"
name="lyrics-mode"
checked={lyricsMode === 'standard'}
onChange={() => setLyricsMode('standard')}
style={{ marginTop: 3 }}
/>
<span>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeStandard')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.lyricsModeStandardDesc')}
</div>
</span>
</label>
<label
style={{
flex: 1, minWidth: 220, cursor: 'pointer',
display: 'flex', gap: '0.6rem', alignItems: 'flex-start',
padding: '0.5rem', borderRadius: 6,
background: lyricsMode === 'lyricsplus' ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'transparent',
border: `1px solid ${lyricsMode === 'lyricsplus' ? 'var(--accent)' : 'transparent'}`,
}}
>
<input
type="radio"
name="lyrics-mode"
checked={lyricsMode === 'lyricsplus'}
onChange={() => setLyricsMode('lyricsplus')}
style={{ marginTop: 3 }}
/>
<span>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeLyricsplus')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.lyricsModeLyricsplusDesc')}
</div>
</span>
</label>
</div>
</div>
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
{lyricsMode === 'standard' && (
<div className="settings-card" style={{ padding: '4px 0' }} ref={containerRef} onMouseMove={handleMouseMove}>
{lyricsSources.map((src, i) => {
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
return (
<div
key={src.id}
data-lyrics-idx={i}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
}}
>
<LyricsSourceGripHandle idx={i} label={label} />
<span style={{ flex: 1, fontSize: 14, opacity: src.enabled ? 1 : 0.45 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={src.enabled} onChange={() => toggleSource(src.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
)}
</section>
);
}
+17
View File
@@ -69,6 +69,17 @@ interface AuthState {
lyricsServerFirst: boolean;
enableNeteaselyrics: boolean;
lyricsSources: LyricsSourceConfig[];
/**
* `'standard'` server + lrclib + netease pipeline (configurable order).
* `'lyricsplus'` YouLyPlus / lyricsplus first, silent fallback to standard
* pipeline when no data is returned.
*/
lyricsMode: 'standard' | 'lyricsplus';
/**
* Render synced lines as static text (no auto-scroll, no word highlighting).
* Honoured in both lyrics modes.
*/
lyricsStaticOnly: boolean;
showFullscreenLyrics: boolean;
showFsArtistPortrait: boolean;
/** Portrait dimming 0100 (percent), applied as CSS rgba alpha */
@@ -198,6 +209,8 @@ interface AuthState {
setLyricsServerFirst: (v: boolean) => void;
setEnableNeteaselyrics: (v: boolean) => void;
setLyricsSources: (sources: LyricsSourceConfig[]) => void;
setLyricsMode: (v: 'standard' | 'lyricsplus') => void;
setLyricsStaticOnly: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setShowFsArtistPortrait: (v: boolean) => void;
setFsPortraitDim: (v: number) => void;
@@ -299,6 +312,8 @@ export const useAuthStore = create<AuthState>()(
lyricsServerFirst: true,
enableNeteaselyrics: false,
lyricsSources: DEFAULT_LYRICS_SOURCES,
lyricsMode: 'standard',
lyricsStaticOnly: false,
showFullscreenLyrics: true,
showFsArtistPortrait: true,
fsPortraitDim: 28,
@@ -424,6 +439,8 @@ export const useAuthStore = create<AuthState>()(
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
setLyricsSources: (sources) => set({ lyricsSources: sources }),
setLyricsMode: (v) => set({ lyricsMode: v }),
setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }),
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
+49
View File
@@ -2694,6 +2694,33 @@
color: var(--text-secondary);
}
/* Word-sync (karaoke) mode per-word highlight inside the active line.
The parent `.lyrics-line.active` colour is overridden: only `.active` / `.played`
words carry accent tint, the remaining words stay at the line's base colour. */
.lyrics-word-synced .lyrics-line {
color: var(--text-secondary);
}
.lyrics-word-synced .lyrics-line.active {
color: var(--text-secondary); /* line colour neutralised; words decide */
}
.lyrics-word-synced .lyrics-line.completed {
color: var(--text-muted);
}
.lyrics-word {
display: inline;
transition: color 0.18s ease;
color: inherit;
}
.lyrics-word.played {
color: var(--accent);
opacity: 0.65;
}
.lyrics-word.active {
color: var(--accent);
opacity: 1;
text-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent);
}
.lyrics-plain {
padding: 4px 0 16px;
}
@@ -3555,6 +3582,28 @@
transform: scaleX(1.015); /* subtle compositor-only emphasis, no layout cost */
}
/* Fullscreen word-sync (karaoke). Parent .fsl-active base colour stays dim so
only the .active/.played words pop. */
.fs-lyric-line.fsl-active .fs-lyric-word {
color: rgba(255, 255, 255, 0.35);
text-shadow: none;
}
.fs-lyric-word {
display: inline;
/* Preserve the trailing space the lyricsplus API embeds in each syllabus
token (`"Gina "`, `"dreams "`, ). Without this the flex formatting
context collapses it and words run together. */
white-space: pre;
transition: color 0.18s ease, text-shadow 0.18s ease;
}
.fs-lyric-line.fsl-active .fs-lyric-word.played {
color: rgba(255, 255, 255, 0.75);
}
.fs-lyric-line.fsl-active .fs-lyric-word.active {
color: #ffffff;
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
}
/* no-compositing overrides
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).