mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 износ и архивиране',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 エクスポートとアーカイブ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 и архивов',
|
||||
|
||||
@@ -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 导出与归档',
|
||||
|
||||
Reference in New Issue
Block a user