diff --git a/modules/lyrics-plus/config.ts b/modules/lyrics-plus/config.ts index 501c06b..ebe5ac4 100644 --- a/modules/lyrics-plus/config.ts +++ b/modules/lyrics-plus/config.ts @@ -129,6 +129,7 @@ export const CONFIG = { providersOrder, modes: ["karaoke", "synced", "unsynced", "genius"] satisfies LyricMode[], locked: Number.parseInt(localStorage.getItem("lyrics-plus:lock-mode") || "-1"), + preferredMode: Number.parseInt(localStorage.getItem("lyrics-plus:preferred-mode") || "-1"), }; export function isProviderKey(value: unknown): value is ProviderKey { diff --git a/modules/lyrics-plus/container-state.test.mts b/modules/lyrics-plus/container-state.test.mts index 18071fd..0fb456a 100644 --- a/modules/lyrics-plus/container-state.test.mts +++ b/modules/lyrics-plus/container-state.test.mts @@ -7,7 +7,8 @@ import "../stdlib/lib/test-setup.mts"; import assert from "node:assert/strict"; import { test } from "node:test"; import { createElement } from "react"; -import { translationMode, renderedLines, plainLines, mergeProviderResult } from "./container-state.ts"; +import { translationMode, renderedLines, plainLines, mergeProviderResult, pickMode } from "./container-state.ts"; +import { GENIUS, KARAOKE, SYNCED, UNSYNCED } from "./config.ts"; import type { DisplayLyricLine, ProviderResult } from "./types.ts"; test("translation modes follow the source language and reject invalid persisted values", () => { @@ -75,3 +76,17 @@ test("provider fallback does not overwrite null fields omitted by the next provi assert.equal(first.neteaseTranslation, null); assert.equal(Object.hasOwn(first, "mode"), false); }); + +test("mode auto-pick prefers the chosen mode and only falls back to karaoke last", () => { + const lines = [{ text: "words", startTime: 0 }]; + const all = { karaoke: [{ text: [], startTime: 0 }], synced: lines, unsynced: lines }; + assert.equal(pickMode(all, -1), KARAOKE); + assert.equal(pickMode(all, SYNCED), SYNCED); + assert.equal(pickMode(all, UNSYNCED), UNSYNCED); + assert.equal(pickMode({ karaoke: all.karaoke, unsynced: lines }, SYNCED), UNSYNCED); + assert.equal(pickMode({ synced: lines, unsynced: lines }, KARAOKE), SYNCED); + assert.equal(pickMode({ karaoke: all.karaoke }, SYNCED), KARAOKE); + assert.equal(pickMode({ genius: "text" }, SYNCED), GENIUS); + assert.equal(pickMode({ synced: lines }, Number.NaN), SYNCED); + assert.equal(pickMode({}, SYNCED), -1); +}); diff --git a/modules/lyrics-plus/container-state.ts b/modules/lyrics-plus/container-state.ts index e4496a6..6eef199 100644 --- a/modules/lyrics-plus/container-state.ts +++ b/modules/lyrics-plus/container-state.ts @@ -10,7 +10,9 @@ import { type LyricLine, type TranslationMode, type ProviderResult, + type LyricMode, } from "./types.ts"; +import { CONFIG, GENIUS, KARAOKE, SYNCED, UNSYNCED } from "./config.ts"; import { lyricText, isKaraokeWords } from "./utils.ts"; export function translationMode( @@ -77,3 +79,20 @@ export function mergeProviderResult(target: ProviderResult, source: ProviderResu }; keys.forEach(copy); } + +const AUTO_MODE_ORDER = [KARAOKE, SYNCED, UNSYNCED, GENIUS]; + +/** + * Pick a mode based on user preference and availability. + * + * Karaoke is only a last resort once another mode is preferred, + * so a Synced/Unsynced or other user chosen mode will never switch to Karaoke on its own + * but Karaoke will still fall back to other modes if lyrics doesn't support Karaoke mode. + */ +export function pickMode(lyrics: Pick, preferred: number): number { + const order = + preferred === KARAOKE || !AUTO_MODE_ORDER.includes(preferred) + ? AUTO_MODE_ORDER + : [preferred, ...AUTO_MODE_ORDER.filter((mode) => mode !== preferred && mode !== KARAOKE), KARAOKE]; + return order.find((mode) => lyrics[CONFIG.modes[mode]]) ?? -1; +} diff --git a/modules/lyrics-plus/mod.tsx b/modules/lyrics-plus/mod.tsx index e3f3dcf..ebf636c 100644 --- a/modules/lyrics-plus/mod.tsx +++ b/modules/lyrics-plus/mod.tsx @@ -36,7 +36,7 @@ import { UNSYNCED, } from "./config.ts"; import * as UtilsPure from "./utils.ts"; -import { translationMode, renderedLines, plainLines, mergeProviderResult } from "./container-state.ts"; +import { translationMode, renderedLines, plainLines, mergeProviderResult, pickMode } from "./container-state.ts"; import type { CSSProperties, ReactNode, ReactElement, ChangeEvent } from "react"; import { type DisplayLyricLine, @@ -724,16 +724,7 @@ export class LyricsContainer extends react.Component { } else if (this.state.lockMode !== -1) { finalMode = this.state.lockMode; } else { - // Auto switch - if (tempState.karaoke) { - finalMode = KARAOKE; - } else if (tempState.synced) { - finalMode = SYNCED; - } else if (tempState.unsynced) { - finalMode = UNSYNCED; - } else if (tempState.genius) { - finalMode = GENIUS; - } + finalMode = pickMode(tempState, CONFIG.preferredMode); } } @@ -1319,16 +1310,7 @@ export class LyricsContainer extends react.Component { } else if (this.state.lockMode !== -1) { mode = this.state.lockMode; } else { - // Auto switch - if (this.state.karaoke) { - mode = KARAOKE; - } else if (this.state.synced) { - mode = SYNCED; - } else if (this.state.unsynced) { - mode = UNSYNCED; - } else if (this.state.genius) { - mode = GENIUS; - } + mode = pickMode(this.state, CONFIG.preferredMode); } let activeItem; @@ -1552,6 +1534,8 @@ export class LyricsContainer extends react.Component { const info = this.infoFromTrack(client.player.data.item); if (info?.uri) this.props.queries.rememberMode(info.uri, mode); + CONFIG.preferredMode = mode; + localStorage.setItem("lyrics-plus:preferred-mode", String(mode)); this.setState({ explicitMode: mode }); if (this.state.provider !== "local") this.fetchLyrics(client.player.data.item, mode); }