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:
@@ -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
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user