feat(lyrics): word-level lyrics from OpenSubsonic songLyrics v2 (#1265)

* feat(lyrics): read word-level timing from OpenSubsonic songLyrics v2

Navidrome 0.63 advertises songLyrics v2, which adds word/syllable cues
behind a new `enhanced` flag on getLyricsBySongId. The lyrics pane already
renders word-level highlighting for the lyricsplus provider, so the server
source now feeds the same renderer.

- Request `enhanced` only where the capability catalog says the server
  speaks v2 (Navidrome 0.63+), since no spec forces a v1 server to ignore
  an unknown parameter.
- `enhanced=true` also returns translation and pronunciation layers, so the
  response is filtered to the main layer before display.
- Map cue lines to word lines: a missing cue `end` is all-or-nothing per
  line and resolves from the next cue, then the line end; multi-voice lines
  share an index and keep the main agent; a line without cues stays a single
  full-line word so no line is dropped.
- Bump the lyrics IndexedDB cache so line-only entries cached under the
  90-day TTL cannot suppress word timing.
- Move the shared lyrics value types out of the hook, which also drops the
  useLyrics/lyricsPersistentCache import cycle.

* i18n(settings): add the server word-sync hint

One line explaining what word-by-word lyrics need from the server.

* feat(settings): surface the server word-sync requirement in Lyrics Sources

Word-by-word lyrics need a recent server and lyrics that actually carry word
timing, which is not obvious from the source list alone.

Rebuild the block on the settings sub-card primitives while adding the hint:
the section description moves into the SettingsSubSection slot, the source
list and its ordering hints become a SettingsSubCard/SettingsField, and the
hint uses the field's note slot instead of a hand-rolled div borrowing an
unrelated class.

* docs(changelog): server word-level lyrics (#1265)

Adds the changelog entry, the contributor credit, and a What's New highlight
for the 1.50.0 line.
This commit is contained in:
Psychotoxical
2026-07-10 02:57:49 +02:00
committed by GitHub
parent 4bae7005cd
commit a307f04b88
31 changed files with 606 additions and 109 deletions
+7
View File
@@ -87,6 +87,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Full Italian (Italiano) UI translation — selectable from the language picker on the Settings and Login screens.
### Lyrics — word-by-word highlighting straight from your server
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1265](https://github.com/Psychotoxical/psysonic/pull/1265)**
* The **Server** lyrics source now highlights lyrics word by word, so karaoke sync no longer depends on the third-party YouLyPlus backend. Requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC); anything else keeps highlighting line by line.
* **Settings → Lyrics → Lyrics Sources** spells out those requirements, and the block now follows the standard settings sub-card layout.
## Changed
+4
View File
@@ -12,6 +12,10 @@ Within each section, order by **user impact** (most noticeable first) — not PR
## Highlights
### Lyrics that follow the singer, word by word
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
### Bulgarian — now in your language
- Psysonic is now available in **Bulgarian (Български)** — pick it from the language menu on the **Settings** and **Login** screens.
+1
View File
@@ -414,6 +414,7 @@ const CONTRIBUTOR_ENTRIES = [
'Theme Store: per-theme changelogs shown as an expandable "What\'s new" on each card, plus installed themes with an available update pinned to the top of the list (PR #1240)',
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
'Word-by-word lyrics from the server via OpenSubsonic songLyrics v2, no third-party backend needed (PR #1265)',
],
},
{
@@ -3,7 +3,8 @@ import { useEffect, useRef, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { LrcLine } from '@/features/lyrics/api/lrclib';
import { useLyrics, type WordLyricsLine } from '@/features/lyrics/hooks/useLyrics';
import { useLyrics } from '@/features/lyrics/hooks/useLyrics';
import type { WordLyricsLine } from '@/features/lyrics/types';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import type { Track } from '@/lib/media/trackTypes';
+12 -47
View File
@@ -1,5 +1,4 @@
import { getLyricsBySongId } from '@/lib/api/subsonicLyrics';
import type { SubsonicStructuredLyrics } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
@@ -11,56 +10,16 @@ import { useAuthStore } from '@/store/authStore';
import { useOfflineStore } from '@/features/offline';
import { useHotCacheStore } from '@/features/playback/store/hotCacheStore';
import { getCachedLyrics, putCachedLyrics, lyricsCacheKey } from '@/features/lyrics/utils/lyricsPersistentCache';
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;
}
import { parseStructuredLyrics, parseStructuredWordLines } from '@/features/lyrics/utils/structuredLyrics';
import { FEATURE_ENHANCED_LYRICS } from '@/lib/serverCapabilities/catalog';
import { isFeatureActiveForServer } from '@/lib/serverCapabilities/storeView';
import type { CachedLyrics, LyricsSource, WordLyricsLine } from '@/features/lyrics/types';
// L1 cache: RAM, survives tab switches and component remount within a session.
// L2 (IndexedDB) lives in `utils/lyricsPersistentCache.ts` — only touched on
// L1 miss so the common case (jumping back to a recent track) stays fully sync.
export const lyricsCache = new Map<string, CachedLyrics>();
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
export function parseStructuredLyrics(
lyrics: SubsonicStructuredLyrics,
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
// Accept both `synced` (OpenSubsonic spec) and `issynced` (legacy servers).
const isSynced = !!(lyrics.synced ?? lyrics.issynced);
if (isSynced && lyrics.line.length > 0) {
const lines: LrcLine[] = lyrics.line
.filter(l => l.start !== undefined)
.map(l => ({ time: l.start! / 1000, text: l.value.trim() }))
.sort((a, b) => a.time - b.time);
if (lines.length > 0) return { syncedLines: lines, plainLyrics: null };
}
const plain = lyrics.line.map(l => l.value).join('\n').trim();
return { syncedLines: null, plainLyrics: plain || null };
}
export interface UseLyricsResult {
syncedLines: LrcLine[] | null;
wordLines: WordLyricsLine[] | null;
@@ -174,11 +133,17 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
};
const fetchServer = async (): Promise<boolean> => {
const structured = await getLyricsBySongId(currentTrack.id);
// `songLyrics` v2 adds word-level cues, but only where the catalog says the
// server speaks it. On a v1 server this stays a plain v1 request.
const serverId = useAuthStore.getState().activeServerId ?? '';
const enhanced = !!serverId && isFeatureActiveForServer(serverId, FEATURE_ENHANCED_LYRICS);
const structured = await getLyricsBySongId(currentTrack.id, { enhanced });
if (!structured) return false;
const parsed = parseStructuredLyrics(structured);
if (!parsed.syncedLines && !parsed.plainLyrics) return false;
store({ ...parsed, wordLines: null, source: 'server', notFound: false });
const wordLines = enhanced ? parseStructuredWordLines(structured) : null;
store({ ...parsed, wordLines, source: 'server', notFound: false });
return true;
};
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
import type { Track } from '@/lib/media/trackTypes';
import type { WordLyricsLine } from '@/features/lyrics/hooks/useLyrics';
import type { WordLyricsLine } from '@/features/lyrics/types';
interface Args {
enabled: boolean;
+2 -1
View File
@@ -10,6 +10,7 @@
* subsonic 'server' lyrics provider stays in `lib/api/subsonicLyrics`.
*/
export { default as LyricsPane } from './components/LyricsPane';
export { useLyrics, type WordLyricsLine, type LyricsSource } from './hooks/useLyrics';
export { useLyrics } from './hooks/useLyrics';
export { useWordLyricsSync } from './hooks/useWordLyricsSync';
export type { LrcLine } from './api/lrclib';
export type { WordLyricsLine, WordLyricsWord, LyricsSource } from './types';
+35
View File
@@ -0,0 +1,35 @@
/**
* Shared lyrics value types. They live here rather than in `hooks/useLyrics`
* so the cache and the parsers can consume them without importing back into
* the hook (dependency-cruiser counts type-only edges, and a back-edge would
* close a cycle).
*/
import type { LrcLine } from '@/features/lyrics/api/lrclib';
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 provider responses.
*/
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;
}
@@ -12,10 +12,16 @@
* - notFound entries: 7 days. Lets the user / server admin add lyrics
* later without an indefinite negative cache.
*/
import type { CachedLyrics } from '@/features/lyrics/hooks/useLyrics';
import type { CachedLyrics } from '@/features/lyrics/types';
const DB_NAME = 'psysonic-lyrics-cache';
const STORE_NAME = 'lyrics';
/**
* 2 — server lyrics may now carry word-level timing. Entries cached as
* line-only under v1 would otherwise suppress karaoke for their full 90-day
* TTL, so the upgrade drops them once and the next play refetches.
*/
const DB_VERSION = 2;
const TTL_FOUND_MS = 90 * 24 * 60 * 60 * 1000;
const TTL_NOT_FOUND_MS = 7 * 24 * 60 * 60 * 1000;
@@ -33,12 +39,15 @@ function openDB(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise(resolve => {
try {
const req = indexedDB.open(DB_NAME, 1);
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = e => {
const database = (e.target as IDBOpenDBRequest).result;
const request = e.target as IDBOpenDBRequest;
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'key' });
return;
}
request.transaction?.objectStore(STORE_NAME).clear();
};
req.onsuccess = e => {
db = (e.target as IDBOpenDBRequest).result;
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicStructuredLyrics } from '@/lib/api/subsonicTypes';
import { parseStructuredLyrics, parseStructuredWordLines } from '@/features/lyrics/utils/structuredLyrics';
/** `byteStart`/`byteEnd` are required by the type but unused by the mapper (we read `value`). */
function cue(start: number, value: string, end?: number) {
return { start, end, value, byteStart: 0, byteEnd: value.length - 1 };
}
describe('parseStructuredLyrics', () => {
it('converts synced ms timestamps to seconds and sorts', () => {
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 2000, value: ' second ' }, { start: 500, value: 'first' }],
};
expect(parseStructuredLyrics(lyrics)).toEqual({
syncedLines: [{ time: 0.5, text: 'first' }, { time: 2, text: 'second' }],
plainLyrics: null,
});
});
it('falls back to plain text when unsynced', () => {
const lyrics: SubsonicStructuredLyrics = { line: [{ value: 'a' }, { value: 'b' }] };
expect(parseStructuredLyrics(lyrics)).toEqual({ syncedLines: null, plainLyrics: 'a\nb' });
});
});
describe('parseStructuredWordLines', () => {
it('returns null without cue lines (songLyrics v1 response)', () => {
expect(parseStructuredWordLines({ synced: true, line: [{ start: 0, value: 'a' }] })).toBeNull();
});
it('returns null for unsynced lyrics even when cues are present', () => {
const lyrics: SubsonicStructuredLyrics = {
line: [{ value: 'a' }],
cueLine: [{ index: 0, value: 'a', cue: [cue(0, 'a')] }],
};
expect(parseStructuredWordLines(lyrics)).toBeNull();
});
it('returns null when a line lacks a start time', () => {
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 0, value: 'a' }, { value: 'b' }],
cueLine: [{ index: 0, value: 'a', cue: [cue(0, 'a')] }],
};
expect(parseStructuredWordLines(lyrics)).toBeNull();
});
it('returns null when line starts are out of order', () => {
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 2000, value: 'a' }, { start: 1000, value: 'b' }],
cueLine: [{ index: 0, value: 'a', cue: [cue(2000, 'a')] }],
};
expect(parseStructuredWordLines(lyrics)).toBeNull();
});
it('derives a missing cue end from the next cue, then from the line end', () => {
// Navidrome emits `end` all-or-nothing per cue line; here it emits none.
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 1000, value: 'hello world' }],
cueLine: [{
index: 0,
start: 1000,
end: 2500,
value: 'hello world',
cue: [cue(1000, 'hello '), cue(1800, 'world')],
}],
};
expect(parseStructuredWordLines(lyrics)).toEqual([{
time: 1,
duration: 1.5,
text: 'hello world',
words: [
{ text: 'hello ', time: 1, duration: 0.8 },
{ text: 'world', time: 1.8, duration: 0.7 },
],
}]);
});
it('uses an explicit cue end when the server provides one', () => {
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 0, value: 'hi' }],
cueLine: [{ index: 0, end: 1000, value: 'hi', cue: [cue(0, 'hi', 400)] }],
};
const [line] = parseStructuredWordLines(lyrics)!;
expect(line.words).toEqual([{ text: 'hi', time: 0, duration: 0.4 }]);
expect(line.duration).toBe(1);
});
it('keeps a cue-less line as one full-line word so no line is dropped', () => {
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 1000, value: 'sung' }, { start: 3000, value: 'instrumental' }],
cueLine: [{ index: 0, end: 2000, value: 'sung', cue: [cue(1000, 'sung', 2000)] }],
};
const lines = parseStructuredWordLines(lyrics)!;
expect(lines).toHaveLength(2);
expect(lines[1]).toEqual({
time: 3,
duration: 0,
text: 'instrumental',
words: [{ text: 'instrumental', time: 3, duration: 0 }],
});
});
it('renders only the first cue line per index (main agent wins over backing vocals)', () => {
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 0, value: 'lead' }],
agents: [{ id: 'v1', role: 'main' }, { id: 'v2', role: 'bg' }],
cueLine: [
{ index: 0, end: 1000, value: 'lead', agentId: 'v1', cue: [cue(0, 'lead', 1000)] },
{ index: 0, end: 1000, value: 'ooh', agentId: 'v2', cue: [cue(0, 'ooh', 1000)] },
],
};
const lines = parseStructuredWordLines(lyrics)!;
expect(lines).toHaveLength(1);
expect(lines[0].text).toBe('lead');
expect(lines[0].words).toEqual([{ text: 'lead', time: 0, duration: 1 }]);
});
it('falls back to the next line start when the cue line has no end', () => {
const lyrics: SubsonicStructuredLyrics = {
synced: true,
line: [{ start: 0, value: 'a' }, { start: 2000, value: 'b' }],
cueLine: [
{ index: 0, value: 'a', cue: [cue(0, 'a')] },
{ index: 1, value: 'b', cue: [cue(2000, 'b')] },
],
};
const lines = parseStructuredWordLines(lyrics)!;
expect(lines[0].duration).toBe(2);
expect(lines[0].words[0].duration).toBe(2);
});
});
@@ -0,0 +1,129 @@
/**
* Adapters from the OpenSubsonic `getLyricsBySongId` response to the shapes the
* lyrics pane renders. Pure — every timing decision is made here so the fetch
* hook stays a pipeline.
*
* Server timings are milliseconds; `LrcLine` / `WordLyricsLine` are seconds.
*/
import type {
SubsonicLyricCueLine,
SubsonicStructuredLyrics,
} from '@/lib/api/subsonicTypes';
import type { LrcLine } from '@/features/lyrics/api/lrclib';
import type { CachedLyrics, WordLyricsLine, WordLyricsWord } from '@/features/lyrics/types';
const MS_PER_SECOND = 1000;
function isSyncedLyrics(lyrics: SubsonicStructuredLyrics): boolean {
// Accept both `synced` (OpenSubsonic spec) and `issynced` (legacy servers).
return !!(lyrics.synced ?? lyrics.issynced);
}
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
export function parseStructuredLyrics(
lyrics: SubsonicStructuredLyrics,
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
if (isSyncedLyrics(lyrics) && lyrics.line.length > 0) {
const lines: LrcLine[] = lyrics.line
.filter(l => l.start !== undefined)
.map(l => ({ time: l.start! / MS_PER_SECOND, text: l.value.trim() }))
.sort((a, b) => a.time - b.time);
if (lines.length > 0) return { syncedLines: lines, plainLyrics: null };
}
const plain = lyrics.line.map(l => l.value).join('\n').trim();
return { syncedLines: null, plainLyrics: plain || null };
}
/**
* Multi-voice lyrics emit one cue line per agent, all sharing the same `index`,
* with the main-role agent first. The pane renders a single layer, so the first
* cue line per index wins and backing vocals are dropped.
*/
function firstCueLinePerIndex(
cueLines: readonly SubsonicLyricCueLine[],
): Map<number, SubsonicLyricCueLine> {
const byIndex = new Map<number, SubsonicLyricCueLine>();
for (const cueLine of cueLines) {
if (!byIndex.has(cueLine.index)) byIndex.set(cueLine.index, cueLine);
}
return byIndex;
}
interface TimedFragment {
text: string;
/** Milliseconds. */
start: number;
end: number;
}
/**
* Word fragments for one line. `cue[].end` is all-or-nothing across a cue line,
* so when it is absent the end is taken from the next cue, then from the line's
* own end — otherwise the last word of a line would never stop highlighting.
*
* A line without cues (an instrumental break, or a server that timed only some
* lines) becomes a single full-line fragment, so no line disappears from the
* pane just because it carries no word timing.
*/
function fragmentsForLine(
cueLine: SubsonicLyricCueLine | undefined,
text: string,
lineStart: number,
lineEnd: number | undefined,
): TimedFragment[] {
const cues = (cueLine?.cue ?? []).filter(cue => Number.isFinite(cue.start));
if (cues.length === 0) {
return [{ text, start: lineStart, end: lineEnd ?? lineStart }];
}
return cues.map((cue, i) => {
const end = cue.end ?? cues[i + 1]?.start ?? lineEnd ?? cue.start;
return { text: cue.value, start: cue.start, end: Math.max(cue.start, end) };
});
}
function toWord(fragment: TimedFragment): WordLyricsWord {
return {
text: fragment.text,
time: fragment.start / MS_PER_SECOND,
duration: Math.max(0, fragment.end - fragment.start) / MS_PER_SECOND,
};
}
/**
* Convert `songLyrics` v2 cue lines into karaoke word lines, or null when the
* response cannot drive word highlighting. Callers fall back to line-level sync.
*
* Null is returned for unsynced lyrics, for a response without cue lines, and
* for line timings that are missing or out of order — a half-timed pane desyncs
* far more visibly than plain line highlighting.
*/
export function parseStructuredWordLines(
lyrics: SubsonicStructuredLyrics,
): WordLyricsLine[] | null {
const cueLines = lyrics.cueLine ?? [];
if (!isSyncedLyrics(lyrics) || cueLines.length === 0 || lyrics.line.length === 0) return null;
const starts = lyrics.line.map(l => l.start);
if (starts.some(start => start === undefined)) return null;
const ordered = starts as number[];
if (ordered.some((start, i) => i > 0 && start < ordered[i - 1])) return null;
// `cueLine.index` addresses the original `line[]` positions, so this list must
// keep the server's order — unlike `parseStructuredLyrics`, which may sort.
const byIndex = firstCueLinePerIndex(cueLines);
return lyrics.line.map((line, index) => {
const cueLine = byIndex.get(index);
const lineStart = ordered[index];
const lineEnd = cueLine?.end ?? ordered[index + 1];
const text = cueLine?.value ?? line.value;
const fragments = fragmentsForLine(cueLine, text, lineStart, lineEnd);
const end = lineEnd ?? fragments[fragments.length - 1].end;
return {
time: lineStart / MS_PER_SECOND,
duration: Math.max(0, end - lineStart) / MS_PER_SECOND,
text,
words: fragments.map(toWord),
};
});
}
@@ -7,6 +7,7 @@ import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
server: 'settings.lyricsSourceServer',
@@ -42,59 +43,55 @@ export function LyricsSourcesCustomizer() {
return (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('settings.lyricsSourcesDesc')}
</p>
{/* YouLyPlus (karaoke) independent toggle. When on it is tried first and
the enabled sources below act as fallback; when off only those sources
are used. YouLyPlus off + every source off = lyrics fully disabled. */}
<div style={{ marginBottom: '0.75rem' }}>
<SettingsToggle
label={t('settings.lyricsYouLyPlus')}
desc={t('settings.lyricsYouLyPlusDesc')}
checked={youLyPlusEnabled}
onChange={setYouLyPlusEnabled}
/>
</div>
<SettingsToggle
label={t('settings.lyricsYouLyPlus')}
desc={t('settings.lyricsYouLyPlusDesc')}
checked={youLyPlusEnabled}
onChange={setYouLyPlusEnabled}
/>
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
</div>
<div style={{ padding: '4px 0', marginBottom: '0.75rem' }} ref={setContainer} onMouseMove={onMouseMove}>
{lyricsSources.map((src) => {
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
const edge = isDragging ? dropEdge(src.id) : null;
return (
<div
key={src.id}
data-reorder-id={src.id}
className="sidebar-customizer-row"
style={{
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<ReorderGripHandle id={src.id} type={REORDER_TYPE} 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>
<SettingsSubCard style={{ margin: '0.85rem 0' }}>
<SettingsField
desc={youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
note={t('settings.lyricsServerWordSyncHint')}
>
<div ref={setContainer} onMouseMove={onMouseMove}>
{lyricsSources.map((src) => {
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
const edge = isDragging ? dropEdge(src.id) : null;
return (
<div
key={src.id}
data-reorder-id={src.id}
className="sidebar-customizer-row"
style={{
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<ReorderGripHandle id={src.id} type={REORDER_TYPE} 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>
</SettingsField>
</SettingsSubCard>
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div style={{ marginBottom: '0.75rem' }}>
<SettingsToggle
label={t('settings.lyricsStaticOnly')}
desc={t('settings.lyricsStaticOnlyDesc')}
checked={lyricsStaticOnly}
onChange={setLyricsStaticOnly}
/>
</div>
<SettingsToggle
label={t('settings.lyricsStaticOnly')}
desc={t('settings.lyricsStaticOnlyDesc')}
checked={lyricsStaticOnly}
onChange={setLyricsStaticOnly}
/>
</>
);
}
@@ -26,6 +26,7 @@ export function LyricsTab() {
<SettingsSubSection
title={t('settings.lyricsSourcesTitle')}
icon={<Music2 size={16} />}
description={t('settings.lyricsSourcesDesc')}
>
<SettingsGroup>
<LyricsSourcesCustomizer />
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicStructuredLyrics } from '@/lib/api/subsonicTypes';
const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() }));
vi.mock('@/lib/api/subsonicClient', () => ({ api: apiMock }));
import { getLyricsBySongId, isMainLyricsKind, pickMainStructuredLyrics } from '@/lib/api/subsonicLyrics';
function lyrics(overrides: Partial<SubsonicStructuredLyrics> = {}): SubsonicStructuredLyrics {
return { line: [{ start: 0, value: 'la' }], ...overrides };
}
beforeEach(() => {
apiMock.mockReset();
});
describe('isMainLyricsKind', () => {
it('treats a missing kind as main (songLyrics v1 never sends one)', () => {
expect(isMainLyricsKind(lyrics())).toBe(true);
});
it('rejects translation and pronunciation layers', () => {
expect(isMainLyricsKind(lyrics({ kind: 'translation' }))).toBe(false);
expect(isMainLyricsKind(lyrics({ kind: 'pronunciation' }))).toBe(false);
});
});
describe('pickMainStructuredLyrics', () => {
it('never returns a translation layer in place of the original text', () => {
const translation = lyrics({ kind: 'translation', synced: true, line: [{ start: 0, value: 'übersetzt' }] });
const main = lyrics({ kind: 'main', synced: false, line: [{ value: 'original' }] });
// The synced translation comes first — a naive "first synced" pick would take it.
expect(pickMainStructuredLyrics([translation, main])).toBe(main);
});
it('prefers a synced main layer over an unsynced one', () => {
const unsynced = lyrics({ line: [{ value: 'plain' }] });
const synced = lyrics({ synced: true });
expect(pickMainStructuredLyrics([unsynced, synced])).toBe(synced);
});
it('accepts the legacy issynced casing', () => {
const unsynced = lyrics({ line: [{ value: 'plain' }] });
const synced = lyrics({ issynced: true });
expect(pickMainStructuredLyrics([unsynced, synced])).toBe(synced);
});
it('falls back to the unfiltered list when no entry is main', () => {
const translation = lyrics({ kind: 'translation' });
expect(pickMainStructuredLyrics([translation])).toBe(translation);
});
it('returns null for an empty list', () => {
expect(pickMainStructuredLyrics([])).toBeNull();
});
});
describe('getLyricsBySongId', () => {
it('omits the enhanced parameter by default', async () => {
apiMock.mockResolvedValue({ lyricsList: { structuredLyrics: [lyrics()] } });
await getLyricsBySongId('song-1');
expect(apiMock).toHaveBeenCalledWith('getLyricsBySongId.view', { id: 'song-1' });
});
it('requests enhanced data when asked', async () => {
apiMock.mockResolvedValue({ lyricsList: { structuredLyrics: [lyrics()] } });
await getLyricsBySongId('song-1', { enhanced: true });
expect(apiMock).toHaveBeenCalledWith('getLyricsBySongId.view', { id: 'song-1', enhanced: true });
});
it('returns null when the track has no lyrics', async () => {
apiMock.mockResolvedValue({ lyricsList: {} });
await expect(getLyricsBySongId('song-1')).resolves.toBeNull();
});
it('returns null when the server does not support the endpoint', async () => {
apiMock.mockRejectedValue(new Error('not supported'));
await expect(getLyricsBySongId('song-1')).resolves.toBeNull();
});
});
+44 -9
View File
@@ -1,21 +1,56 @@
import { api } from '@/lib/api/subsonicClient';
import type { SubsonicStructuredLyrics } from '@/lib/api/subsonicTypes';
export interface GetLyricsOptions {
/**
* Request OpenSubsonic `songLyrics` v2 data (word/syllable cues, layer kinds,
* multi-voice agents). Only pass this when the server advertises v2 an
* unknown query parameter is not guaranteed to be ignored by every server.
*/
enhanced?: boolean;
}
/**
* Fetches structured lyrics from the server's embedded tags via the
* OpenSubsonic `getLyricsBySongId` endpoint. Returns null when the
* server doesn't support the endpoint or the track has no embedded lyrics.
* Prefers synced lyrics over plain when both are present.
* True for the primary lyric layer. `songLyrics` v1 has no `kind` at all and v2
* omits it for the main layer, so a missing `kind` means main.
*/
export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredLyrics | null> {
export function isMainLyricsKind(lyrics: SubsonicStructuredLyrics): boolean {
return !lyrics.kind || lyrics.kind === 'main';
}
/**
* Choose the layer to display, preferring synced over unsynced.
*
* Without `enhanced` the server returns main-kind entries only, so the filter is
* a no-op. With `enhanced=true` it also returns translation and pronunciation
* layers, and those must never be shown in place of the original text. The
* fallback to the unfiltered list only matters for a server that labels every
* entry as non-main showing something then beats showing nothing.
*/
export function pickMainStructuredLyrics(
list: readonly SubsonicStructuredLyrics[],
): SubsonicStructuredLyrics | null {
if (list.length === 0) return null;
const main = list.filter(isMainLyricsKind);
const pool = main.length > 0 ? main : list;
return pool.find(l => l.synced || l.issynced) ?? pool[0] ?? null;
}
/**
* Fetches structured lyrics from the server's embedded tags or sidecar files via
* the OpenSubsonic `getLyricsBySongId` endpoint. Returns null when the server
* doesn't support the endpoint or the track has no lyrics.
*/
export async function getLyricsBySongId(
id: string,
{ enhanced = false }: GetLyricsOptions = {},
): Promise<SubsonicStructuredLyrics | null> {
try {
const data = await api<{ lyricsList: { structuredLyrics?: SubsonicStructuredLyrics[] } }>(
'getLyricsBySongId.view',
{ id },
enhanced ? { id, enhanced: true } : { id },
);
const list = data.lyricsList?.structuredLyrics;
if (!list || list.length === 0) return null;
return list.find(l => l.synced || l.issynced) ?? list[0];
return pickMainStructuredLyrics(data.lyricsList?.structuredLyrics ?? []);
} catch {
// Server doesn't support the endpoint or track has no embedded lyrics
return null;
+45
View File
@@ -278,6 +278,45 @@ export interface SubsonicLyricLine {
value: string;
}
/**
* OpenSubsonic `songLyrics` v2 classification of a lyric layer. A v1 server
* never sends it, and v2 omits it for the primary layer, so an absent `kind`
* means `main`.
*/
export type SubsonicLyricsKind = 'main' | 'translation' | 'pronunciation';
/** One timed text fragment (word or syllable) inside a `SubsonicLyricCueLine`. */
export interface SubsonicLyricCue {
start: number;
/** Milliseconds. All-or-nothing across a cue line: either every cue has it or none does. */
end?: number;
value: string;
/** UTF-8 byte offsets into the parent cue line's `value` (`byteEnd` inclusive). */
byteStart: number;
byteEnd: number;
}
/**
* Word/syllable timing for one line, linked back via `index`. Multi-voice
* lyrics emit several cue lines sharing an `index` (main-role agent first).
*/
export interface SubsonicLyricCueLine {
index: number;
start?: number;
end?: number;
value: string;
agentId?: string;
cue?: SubsonicLyricCue[];
}
/** Vocal layer attribution for multi-voice lyrics (`songLyrics` v2). */
export interface SubsonicLyricAgent {
id: string;
/** `main` | `bg` | `voice` | `group` | … — at least one agent is `main`. */
role: string;
name?: string;
}
export interface SubsonicStructuredLyrics {
/** OpenSubsonic spec field name (Navidrome ≥ 0.50.0 / any OpenSubsonic server). */
synced?: boolean;
@@ -288,4 +327,10 @@ export interface SubsonicStructuredLyrics {
displayArtist?: string;
displayTitle?: string;
line: SubsonicLyricLine[];
/** `songLyrics` v2, `enhanced=true` only. Absent means the primary layer. */
kind?: SubsonicLyricsKind;
/** `songLyrics` v2, `enhanced=true` only. Parallel to `line` via `index`. */
cueLine?: SubsonicLyricCueLine[];
/** `songLyrics` v2, `enhanced=true` only. Present only alongside `cueLine`. */
agents?: SubsonicLyricAgent[];
}
+33
View File
@@ -14,9 +14,11 @@ import type { CapabilityDefinition } from './types';
export const SONIC_SIMILARITY_EXTENSION = 'sonicSimilarity';
export const PLAYBACK_REPORT_EXTENSION = 'playbackReport';
export const SONG_LYRICS_EXTENSION = 'songLyrics';
export const FEATURE_AUDIOMUSE_SIMILAR_TRACKS = 'audiomuse.similarTracks';
export const FEATURE_PLAYBACK_REPORT = 'opensubsonic.playbackReport';
export const FEATURE_ENHANCED_LYRICS = 'opensubsonic.enhancedLyrics';
export const PROBE_OPENSUBSONIC_EXTENSIONS = 'opensubsonic.extensions';
export const PROBE_LEGACY_INSTANT_MIX = 'navidrome.instantMix.legacy';
@@ -24,6 +26,7 @@ export const PROBE_LEGACY_INSTANT_MIX = 'navidrome.instantMix.legacy';
/** Operation names used by the call router. */
export const OP_SIMILAR_TRACKS = 'similarTracks';
export const OP_REPORT_PLAYBACK = 'reportPlayback';
export const OP_GET_LYRICS = 'getLyrics';
export const SERVER_CAPABILITY_CATALOG: CapabilityDefinition[] = [
{
@@ -94,6 +97,36 @@ export const SERVER_CAPABILITY_CATALOG: CapabilityDefinition[] = [
},
],
},
{
// OpenSubsonic `songLyrics` v2 (`enhanced=true`): word/syllable cues for
// karaoke highlighting, plus translation/pronunciation layers we filter out.
//
// The extension probe stores names only, not advertised versions, so the
// version gate rides on the server identity instead: Navidrome ships v2 in
// 0.63.0. That deliberately keeps `enhanced` off for every non-Navidrome
// server — the parameter is additive, but no spec forces a v1 server to
// ignore an unknown one, and a 400 would take lyrics down entirely.
feature: FEATURE_ENHANCED_LYRICS,
labelKey: 'settings.lyricsSourceServer',
strategies: [
{
id: 'opensubsonic.songLyricsEnhanced',
priority: 100,
when: (ctx) => ctx.isNavidrome && ctx.semverGte([0, 63, 0]),
detection: {
kind: 'extension',
probeId: PROBE_OPENSUBSONIC_EXTENSIONS,
extension: SONG_LYRICS_EXTENSION,
},
trust: 'high',
activation: 'auto',
calls: {
[OP_GET_LYRICS]: { endpoint: 'getLyricsBySongId.view', transport: 'opensubsonic' },
},
labelKey: 'settings.lyricsSourceServer',
},
],
},
];
export function getCapabilityDefinition(feature: string): CapabilityDefinition | undefined {
+1
View File
@@ -323,6 +323,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Синхронизация дума по дума от общностен бекенд (Apple Music, Spotify, Musixmatch, QQ). Пробва се първи; включените по-долу източници служат като резервен вариант.',
lyricsSourcesFallbackHint: 'Използва се като резервен вариант, когато YouLyPlus не намери нищо. Плъзни, за да пренаредиш.',
lyricsSourcesPrimaryHint: 'Пробват се по ред. Плъзни, за да пренаредиш. Всички изключени = текстовете са деактивирани.',
lyricsServerWordSyncHint: 'Дума по дума изисква Navidrome 0.63+ и текст с времена за всяка дума, например файлове TTML или Enhanced LRC. Всичко останало се показва ред по ред.',
lyricsStaticOnly: 'Показвай текстовете само като статичен текст',
lyricsStaticOnlyDesc: 'Показва синхронизираните текстове без автоматично превъртане и без открояване на думи.',
downloadsTitle: 'ZIP износ и архивиране',
+1
View File
@@ -320,6 +320,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Wort-für-Wort-Sync über ein Community-Backend (Apple Music, Spotify, Musixmatch, QQ). Wird zuerst versucht; die aktivierten Quellen unten dienen als Fallback.',
lyricsSourcesFallbackHint: 'Dienen als Fallback, wenn YouLyPlus nichts findet. Zum Sortieren ziehen.',
lyricsSourcesPrimaryHint: 'Werden der Reihe nach genutzt. Zum Sortieren ziehen. Alle aus = Lyrics deaktiviert.',
lyricsServerWordSyncHint: 'Wort für Wort braucht Navidrome 0.63+ und Lyrics mit Wort-Timing, etwa TTML- oder Enhanced-LRC-Dateien. Alles andere wird zeilenweise angezeigt.',
lyricsStaticOnly: 'Nur statische Lyrics anzeigen',
lyricsStaticOnlyDesc: 'Synchronisierte Lyrics werden ohne Auto-Scroll und ohne Wort-Hervorhebung als statischer Text dargestellt.',
downloadsTitle: 'ZIP-Export & Archivierung',
+1
View File
@@ -323,6 +323,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Word-by-word sync from a community backend (Apple Music, Spotify, Musixmatch, QQ). Tried first; the enabled sources below act as fallback.',
lyricsSourcesFallbackHint: 'Used as fallback when YouLyPlus finds nothing. Drag to reorder.',
lyricsSourcesPrimaryHint: 'Tried in order. Drag to reorder. All off = lyrics disabled.',
lyricsServerWordSyncHint: 'Word-by-word needs Navidrome 0.63+ and lyrics with word timing, such as TTML or Enhanced LRC files. Everything else shows line by line.',
lyricsStaticOnly: 'Show lyrics as static text only',
lyricsStaticOnlyDesc: 'Render synced lyrics without auto-scroll and without word highlighting.',
downloadsTitle: 'ZIP Export & Archiving',
+1
View File
@@ -319,6 +319,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Sincronización palabra por palabra desde un backend comunitario (Apple Music, Spotify, Musixmatch, QQ). Se prueba primero; las fuentes activadas abajo actúan como respaldo.',
lyricsSourcesFallbackHint: 'Se usan como respaldo cuando YouLyPlus no encuentra nada. Arrastra para reordenar.',
lyricsSourcesPrimaryHint: 'Se prueban en orden. Arrastra para reordenar. Todo desactivado = letras desactivadas.',
lyricsServerWordSyncHint: 'Palabra por palabra requiere Navidrome 0.63+ y letras con tiempos por palabra, como archivos TTML o Enhanced LRC. El resto se muestra línea por línea.',
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',
+1
View File
@@ -307,6 +307,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Synchronisation mot à mot depuis un backend communautaire (Apple Music, Spotify, Musixmatch, QQ). Essayé en premier ; les sources activées ci-dessous servent de repli.',
lyricsSourcesFallbackHint: 'Utilisées en repli quand YouLyPlus ne trouve rien. Glisser pour réordonner.',
lyricsSourcesPrimaryHint: 'Essayées dans l\'ordre. Glisser pour réordonner. Tout désactivé = paroles désactivées.',
lyricsServerWordSyncHint: 'Le mot à mot nécessite Navidrome 0.63+ et des paroles avec minutage par mot, comme des fichiers TTML ou Enhanced LRC. Le reste s\'affiche ligne par ligne.',
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',
+1
View File
@@ -323,6 +323,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Szóról szóra szinkronizálás egy közösségi háttérrendszerből (Apple Music, Spotify, Musixmatch, QQ). Először ezt próbálja; az alábbi engedélyezett források tartalék gyanánt szolgálnak.',
lyricsSourcesFallbackHint: 'Tartalékként használva, ha a YouLyPlus nem talál semmit. Húzd az átrendezéshez.',
lyricsSourcesPrimaryHint: 'Sorrendben próbálva. Húzd az átrendezéshez. Mind kikapcsolva = dalszöveg letiltva.',
lyricsServerWordSyncHint: 'A szavankénti kiemeléshez Navidrome 0.63+ és szavankénti időzítést tartalmazó dalszöveg kell, például TTML vagy Enhanced LRC fájl. Minden más soronként jelenik meg.',
lyricsStaticOnly: 'Dalszöveg megjelenítése csak statikus szövegként',
lyricsStaticOnlyDesc: 'A szinkronizált dalszöveg megjelenítése automatikus görgetés és szókiemelés nélkül.',
downloadsTitle: 'ZIP-exportálás és archiválás',
+1
View File
@@ -324,6 +324,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Sincronizzazione parola per parola da un backend della community (Apple Music, Spotify, Musixmatch, QQ). Provato per primo; le fonti attivate sotto agiscono come riserva.',
lyricsSourcesFallbackHint: 'Usata come riserva quando YouLyPlus non trova nulla. Trascina per riordinare.',
lyricsSourcesPrimaryHint: 'Provate in ordine. Trascina per riordinare. Tutte disattivate = testi disabilitati.',
lyricsServerWordSyncHint: 'Parola per parola richiede Navidrome 0.63+ e testi con temporizzazione per parola, come i file TTML o Enhanced LRC. Tutto il resto viene mostrato riga per riga.',
lyricsStaticOnly: 'Mostra i testi solo come testo statico',
lyricsStaticOnlyDesc: 'Visualizza i testi sincronizzati senza scorrimento automatico e senza evidenziazione delle parole.',
downloadsTitle: 'Esportazione ZIP e archiviazione',
+1
View File
@@ -317,6 +317,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'コミュニティバックエンド (Apple Music, Spotify, Musixmatch, QQ) から単語単位の同期を取得します。最初に試行され、下の有効ソースはフォールバックとして動作します。',
lyricsSourcesFallbackHint: 'YouLyPlus が見つからない場合のフォールバックとして使用します。ドラッグで並べ替えできます。',
lyricsSourcesPrimaryHint: 'この順序で試行します。ドラッグで並べ替えできます。すべてオフ = 歌詞無効。',
lyricsServerWordSyncHint: '単語ごとのハイライトには Navidrome 0.63 以降と、単語単位のタイミングを含む歌詞(TTML や Enhanced LRC ファイルなど)が必要です。それ以外は行単位で表示されます。',
lyricsStaticOnly: '歌詞を静的テキストのみで表示',
lyricsStaticOnlyDesc: '同期歌詞を自動スクロールなし、単語ハイライトなしで表示します。',
downloadsTitle: 'ZIP エクスポートとアーカイブ',
+1
View File
@@ -306,6 +306,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Ord-for-ord-synkronisering fra en fellesskapsbackend (Apple Music, Spotify, Musixmatch, QQ). Prøves først; de aktiverte kildene under fungerer som reserve.',
lyricsSourcesFallbackHint: 'Brukes som reserve når YouLyPlus ikke finner noe. Dra for å endre rekkefølge.',
lyricsSourcesPrimaryHint: 'Prøves i rekkefølge. Dra for å endre rekkefølge. Alt av = tekster deaktivert.',
lyricsServerWordSyncHint: 'Ord for ord krever Navidrome 0.63+ og tekster med tidskoder per ord, som TTML- eller Enhanced LRC-filer. Alt annet vises linje for linje.',
lyricsStaticOnly: 'Vis sangtekst som statisk tekst',
lyricsStaticOnlyDesc: 'Viser synkroniserte tekster uten auto-scroll og uten ord-utheving.',
downloadsTitle: 'ZIP Eksport & Arkivering',
+1
View File
@@ -307,6 +307,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Woord-voor-woord-synchronisatie van een community-backend (Apple Music, Spotify, Musixmatch, QQ). Wordt eerst geprobeerd; de ingeschakelde bronnen hieronder dienen als fallback.',
lyricsSourcesFallbackHint: 'Worden als fallback gebruikt wanneer YouLyPlus niets vindt. Sleep om te herordenen.',
lyricsSourcesPrimaryHint: 'Worden op volgorde geprobeerd. Sleep om te herordenen. Alles uit = songteksten uitgeschakeld.',
lyricsServerWordSyncHint: 'Woord voor woord vereist Navidrome 0.63+ en songteksten met timing per woord, zoals TTML- of Enhanced LRC-bestanden. Al het andere wordt regel voor regel getoond.',
lyricsStaticOnly: 'Alleen statische tekst weergeven',
lyricsStaticOnlyDesc: 'Gesynchroniseerde songteksten worden zonder auto-scroll en zonder woordmarkering als statische tekst weergegeven.',
downloadsTitle: 'ZIP-export & Archivering',
+1
View File
@@ -323,6 +323,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Synchronizacja słowo po słowie z backendu społecznościowego (Apple Music, Spotify, Musixmatch, QQ). Sprawdzane jako pierwsze; włączone źródła poniżej działają jako awaryjne.',
lyricsSourcesFallbackHint: 'Używane jako opcja awaryjna gdy YouLyPlus nic nie znajdzie. Przeciągnij, aby zmienić kolejność.',
lyricsSourcesPrimaryHint: 'Sprawdzane w podanej kolejności. Przeciągnij, aby zmienić kolejność. Wszystkie wyłączone = teksty wyłączone.',
lyricsServerWordSyncHint: 'Tryb słowo po słowie wymaga Navidrome 0.63+ i tekstu z czasami poszczególnych słów, np. plików TTML lub Enhanced LRC. Pozostałe teksty wyświetlane są wiersz po wierszu.',
lyricsStaticOnly: 'Pokaż teksty utworów jako sam statyczny tekst',
lyricsStaticOnlyDesc: 'Wyświetla zsynchronizowane teksty utworów bez automatycznego przewijania i bez podświetlania słów.',
downloadsTitle: 'Eksportowanie i archiwizacja ZIP',
+1
View File
@@ -322,6 +322,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Sincronizare cuvânt-cu-cuvânt dintr-un backend comunitar (Apple Music, Spotify, Musixmatch, QQ). Încercat primul; sursele activate de mai jos servesc drept rezervă.',
lyricsSourcesFallbackHint: 'Folosite ca rezervă când YouLyPlus nu găsește nimic. Trage pentru a reordona.',
lyricsSourcesPrimaryHint: 'Încercate în ordine. Trage pentru a reordona. Toate dezactivate = versuri dezactivate.',
lyricsServerWordSyncHint: 'Modul cuvânt cu cuvânt necesită Navidrome 0.63+ și versuri cu sincronizare pe cuvinte, precum fișierele TTML sau Enhanced LRC. Restul sunt afișate rând cu rând.',
lyricsStaticOnly: 'Afișează versurile doar ca text static',
lyricsStaticOnlyDesc: 'Redă versurile sincronizate fără scroll automat și fără evidențierea cuvintelor.',
downloadsTitle: 'Export ZIP & Arhivare',
+1
View File
@@ -327,6 +327,7 @@ export const settings = {
lyricsYouLyPlusDesc: 'Синхронизация слово-в-слово из общественного бэкенда (Apple Music, Spotify, Musixmatch, QQ). Пробуется первым; включённые источники ниже служат запасным вариантом.',
lyricsSourcesFallbackHint: 'Используются как запасной вариант, когда YouLyPlus ничего не находит. Перетащите для изменения порядка.',
lyricsSourcesPrimaryHint: 'Пробуются по порядку. Перетащите для изменения порядка. Всё выключено = текст отключён.',
lyricsServerWordSyncHint: 'Подсветка по словам требует Navidrome 0.63+ и текста с таймингами отдельных слов, например файлов TTML или Enhanced LRC. Остальное отображается построчно.',
lyricsStaticOnly: 'Показывать текст статично',
lyricsStaticOnlyDesc: 'Синхронизированный текст отображается без авто-прокрутки и подсветки слов.',
downloadsTitle: 'Экспорт ZIP и архивов',
+1
View File
@@ -306,6 +306,7 @@ export const settings = {
lyricsYouLyPlusDesc: '来自社区后端(Apple Music、Spotify、Musixmatch、QQ 音乐)的逐字同步歌词。优先尝试;下方启用的来源作为后备。',
lyricsSourcesFallbackHint: 'YouLyPlus 找不到时用作后备。拖动可重新排序。',
lyricsSourcesPrimaryHint: '按顺序尝试。拖动可重新排序。全部关闭 = 禁用歌词。',
lyricsServerWordSyncHint: '逐字高亮需要 Navidrome 0.63+ 以及包含逐字时间轴的歌词,例如 TTML 或 Enhanced LRC 文件。其他歌词按行显示。',
lyricsStaticOnly: '仅以静态文本显示歌词',
lyricsStaticOnlyDesc: '同步歌词将不自动滚动、不逐字高亮,以静态文本显示。',
downloadsTitle: 'ZIP 导出与归档',