Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions modules/lyrics-plus/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const CONFIG = {
"lines-before": Number.parseInt(localStorage.getItem("lyrics-plus:visual:lines-before") || "0"),
"lines-after": Number.parseInt(localStorage.getItem("lyrics-plus:visual:lines-after") || "2"),
"font-size": Number.parseInt(localStorage.getItem("lyrics-plus:visual:font-size") || "32"),
"romanization-font-size": Number.parseInt(
localStorage.getItem("lyrics-plus:visual:romanization-font-size") || "16",
),
"translate:translated-lyrics-source":
localStorage.getItem("lyrics-plus:visual:translate:translated-lyrics-source") || "none",
"translate:display-mode": localStorage.getItem("lyrics-plus:visual:translate:display-mode") || "replace",
Expand All @@ -110,6 +113,7 @@ export const CONFIG = {
"translation-mode:japanese": localStorage.getItem("lyrics-plus:visual:translation-mode:japanese") || "furigana",
"translation-mode:korean": localStorage.getItem("lyrics-plus:visual:translation-mode:korean") || "romaja",
"translation-mode:chinese": localStorage.getItem("lyrics-plus:visual:translation-mode:chinese") || "cn",
romanization: localStorage.getItem("lyrics-plus:visual:romanization") || "none",
translate: getConfig("lyrics-plus:visual:translate", false),
"ja-detect-threshold": Number.parseInt(localStorage.getItem("lyrics-plus:visual:ja-detect-threshold") || "40"),
"hans-detect-threshold": Number.parseInt(
Expand Down
41 changes: 39 additions & 2 deletions modules/lyrics-plus/container-requests.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import { createRoot, type Root } from "react-dom/client";
import { createModuleQueryClient } from "../stdlib/query.ts";
import { LyricsQueries } from "./queries.ts";
import { ProviderGenius } from "./providers/genius.ts";
import { GENIUS, CONFIG, UNSYNCED } from "./config.ts";
import { GENIUS, CONFIG, SYNCED, UNSYNCED } from "./config.ts";
import type { GeniusVersion, ProviderResult } from "./types.ts";
import type { Translator } from "./translator.ts";

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const noop = "const Component = () => null;";
const exposure = `
export * as React from ${JSON.stringify(import.meta.resolve("react"))};
export const client = { player: { data: { item: null }, origin: { _events: { addListener() {}, removeListener() {} } } }, notify() {} };
export const client = { player: { data: { item: null }, origin: { _events: { addListener() {}, removeListener() {} } } }, notify(...args) { (globalThis.__notifications ??= []).push(args); } };
export const createRegistrar = () => ({});
${noop}
export { Component as NavLink, Component as PlaybarButton };
Expand Down Expand Up @@ -77,7 +78,9 @@ async function mount() {
assert.ok(ref.current);
return { container: ref.current, queries };
}
const notifications = () => ((globalThis as { __notifications?: unknown[][] }).__notifications ??= []);
afterEach(async () => {
notifications().length = 0;
await React.act(async () => roots.splice(0).forEach((root) => root.unmount()));
await Promise.all(cleanups.splice(0).map((fn) => fn()));
ProviderGenius.fetchLyricsVersion = originalFetch;
Expand Down Expand Up @@ -106,6 +109,40 @@ test("Genius columns complete independently and reject superseded versions in th
assert.equal(container.state.genius2, "secondary");
});

test("pinyin appears without a toast on every track, and a failure says what failed", async () => {
const { container } = await mount();
CONFIG.visual.romanization = "pinyin";
const lyrics = [{ text: "你好", startTime: 0 }];
let fail = false;
container.translator = {
convertToPinyin: async (text: string) => {
if (fail) throw new Error("pinyin-pro did not load");
return `pinyin:${text}`;
},
dispose() {},
} as unknown as Translator;
const romanize = async (uri: string) => {
container.currentTrackUri = uri;
await React.act(async () => {
container.syncRomanization(uri, SYNCED, "zh-hans", lyrics);
await new Promise((resolve) => setTimeout(resolve, 0));
});
};

await romanize("spotify:track:first");
await romanize("spotify:track:second");
assert.deepEqual(container.state.pinyin?.lines, [{ startTime: 0, text: "pinyin:你好" }]);
assert.deepEqual(notifications(), []);

fail = true;
await romanize("spotify:track:third");
assert.equal(container.state.pinyin?.lines, null);
assert.equal(notifications().length, 1);
const [message, isError] = notifications()[0]!;
assert.match(String(message), /pinyin/i);
assert.equal(isError, true);
});

test("same-track mode races cannot overwrite the latest result and automatic selection restores a remembered mode", async () => {
const { container, queries } = await mount();
CONFIG.visual.colorful = false;
Expand Down
6 changes: 6 additions & 0 deletions modules/lyrics-plus/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@
line-height: var(--lyrics-line-height);
}

.lyrics-lyricsContainer-LyricsLine .lyrics-lyricsContainer-RomanizedLine {
opacity: 0.5;
font-size: var(--lyrics-romanization-font-size);
line-height: calc(4px + var(--lyrics-romanization-font-size));
}

@media (min-width: 1280px) {
.lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine {
font-weight: 900;
Expand Down
2 changes: 1 addition & 1 deletion modules/lyrics-plus/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "lyrics-plus",
"kind": "app",
"version": "0.3.0",
"version": "0.4.0",
"authors": ["spicetify"],
"description": "Full-featured lyrics: synced, karaoke, unsynced and translated, from Musixmatch, Spotify, LRCLIB and Netease.",
"entries": {
Expand Down
62 changes: 62 additions & 0 deletions modules/lyrics-plus/mod.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
type TrackInfo,
type ProviderResult,
type GeniusVersion,
type RomanizedLine,
} from "./types.ts";
import { ProviderGenius } from "./providers/genius.ts";
import { createProviders, parseCachedLyrics } from "./providers/index.ts";
Expand Down Expand Up @@ -95,6 +96,7 @@ interface LyricsState extends CachedLyrics {
isFADMode: boolean;
isCached: boolean;
language: LyricsLanguage | null;
pinyin: { uri: string; mode: number; lines: RomanizedLine[] | null } | null;
}
interface Track {
uri: string;
Expand Down Expand Up @@ -323,6 +325,8 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
displayMode: TranslationMode | null | undefined;
currentMusixmatchLanguage: string;
_musixmatchTranslationRequestId: symbol | null;
// `${uri}:${mode}` of the in-flight romanization, so repeated renders do not start it again.
romanizing: string | null = null;
viewPort: Element | null = null;
onQueueChange: QueueListener = () => {};
onFontSizeChange: (event: WheelEvent) => void = () => {};
Expand Down Expand Up @@ -368,6 +372,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
isFADMode: false,
isCached: false,
language: null,
pinyin: null,
};
this.currentTrackUri = "";
this.nextTrackUri = "";
Expand Down Expand Up @@ -780,6 +785,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
cn: null,
hk: null,
tw: null,
pinyin: null,
neteaseTranslation: null,
...tempState,
...translationOverrides,
Expand Down Expand Up @@ -899,6 +905,52 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
this.updateCache(lyricsState.uri, resetCache);
}
}

this.syncRomanization(lyricsState.uri, mode, lang, lyrics);
}

syncRomanization(uri: string, mode: number, lang: string | undefined, lyrics: DisplayLyricLine[] | null) {
if (
CONFIG.visual.romanization === "none" ||
(lang !== "zh-hans" && lang !== "zh-hant") ||
(mode !== SYNCED && mode !== UNSYNCED) ||
!lyrics?.length
)
return;
const key = `${uri}:${mode}`;
if ((this.state.pinyin?.uri === uri && this.state.pinyin.mode === mode) || this.romanizing === key) return;

this.romanizing = key;
const generation = this.requestGeneration;
this.romanizeLyrics(lyrics).then((lines) => {
if (this.romanizing === key) this.romanizing = null;
if (!this.active || this.currentTrackUri !== uri || generation !== this.requestGeneration) return;
// A failed conversion is stored too, so it is not retried on every render.
this.setState({ pinyin: { uri, mode, lines: lines ?? null } });
});
}

async romanizeLyrics(displayLyrics: DisplayLyricLine[]): Promise<RomanizedLine[] | undefined> {
const lyrics = plainLines(displayLyrics);

// Runs on its own for every Chinese track, so only a failure is announced;
// the pinyin lines appear in place once they are ready.
if (!this.translator) {
this.translator = new Translator("zh");
}
const translator = this.translator;
try {
return await Promise.all(
lyrics.map(async (lyric) => ({
startTime: lyric.startTime,
text: await translator.convertToPinyin(lyric.text),
})),
);
} catch (error) {
if (!this.active) return;
client.notify("Couldn't load pinyin for these lyrics", true);
console.error(error);
}
}

provideLanguageCode(lyrics: DisplayLyricLine[] | null) {
Expand Down Expand Up @@ -1245,6 +1297,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
this.mounted = false;
++this.requestGeneration;
this._musixmatchTranslationRequestId = null;
this.romanizing = null;
this.translator?.dispose();
void this.props.queries.client.cancelQueries();
sharedCallbacks.setReloadLyrics(undefined);
Expand Down Expand Up @@ -1273,6 +1326,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
...this.styleVariables,
"--lyrics-align-text": CONFIG.visual.alignment,
"--lyrics-font-size": `${CONFIG.visual["font-size"]}px`,
"--lyrics-romanization-font-size": `${CONFIG.visual["romanization-font-size"]}px`,
"--animation-tempo": this.state.tempo,
};

Expand Down Expand Up @@ -1310,6 +1364,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
...this.styleVariables,
"--lyrics-align-text": CONFIG.visual.alignment,
"--lyrics-font-size": `${CONFIG.visual["font-size"]}px`,
"--lyrics-romanization-font-size": `${CONFIG.visual["romanization-font-size"]}px`,
"--animation-tempo": this.state.tempo,
};

Expand Down Expand Up @@ -1346,6 +1401,11 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
this.state.musixmatchTranslation !== null ||
hasMusixmatchLanguages;
const hasPerformer = !!this.state.currentLyrics?.some((line) => line.performer);
const { pinyin } = this.state;
const romanizationLines =
CONFIG.visual.romanization !== "none" && pinyin?.uri === this.state.uri && pinyin.mode === mode
? pinyin.lines
: null;

if (mode !== -1) {
showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED);
Expand All @@ -1371,6 +1431,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
provider: this.state.provider,
copyright: this.state.copyright,
reRenderLyricsPage: this.reRenderLyricsPage,
romanization: romanizationLines,
},
);
} else if (mode === UNSYNCED && this.state.unsynced) {
Expand All @@ -1380,6 +1441,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
provider: this.state.provider,
copyright: this.state.copyright,
reRenderLyricsPage: this.reRenderLyricsPage,
romanization: romanizationLines,
});
} else if (mode === GENIUS && this.state.genius) {
activeItem = react.createElement(GeniusPage, {
Expand Down
22 changes: 22 additions & 0 deletions modules/lyrics-plus/options-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export const TranslationMenu = react.memo(
none: "None",
};

let romanizationOptions: Record<string, string> = {};

const musixmatchDisplay = new Intl.DisplayNames(["en"], { type: "language" });
const availableMusixmatchLanguages = Array.isArray(musixmatchLanguages)
? [...new Set(musixmatchLanguages.filter(Boolean))]
Expand Down Expand Up @@ -212,6 +214,10 @@ export const TranslationMenu = react.memo(
hk: "Traditional Chinese (Hong Kong)",
tw: "Traditional Chinese (Taiwan)",
};
romanizationOptions = {
none: "None",
pinyin: "Pinyin (拼音)",
};
break;
}
}
Expand Down Expand Up @@ -250,6 +256,14 @@ export const TranslationMenu = react.memo(
// for songs in languages that support translation but not Convert (e.g., English), the option is disabled.
when: () => friendlyLanguage,
},
{
desc: "Romanization",
key: "romanization",
kind: "select",
options: romanizationOptions,
// only Chinese has a romanization option (Pinyin) for now.
when: () => friendlyLanguage === "chinese",
},
{
desc: "Convert",
key: "translate",
Expand Down Expand Up @@ -326,6 +340,14 @@ export const AdjustmentsMenu = react.memo(({ mode, hasPerformer }: { mode: numbe
max: fontSizeLimit.max,
step: fontSizeLimit.step,
},
{
desc: "Romanization font size",
key: "romanization-font-size",
kind: "adjust",
min: fontSizeLimit.min,
max: fontSizeLimit.max,
step: fontSizeLimit.step,
},
{
desc: "Track delay",
key: "delay",
Expand Down
37 changes: 36 additions & 1 deletion modules/lyrics-plus/pages.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ const hooks = registerHooks({
return nextLoad(url, context);
},
});
const { LyricsBackground, SearchBar, SyncedLyricsPage, SyncedExpandedLyricsPage, VersionSelector } =
const { LyricsBackground, SearchBar, SyncedLyricsPage, SyncedExpandedLyricsPage, UnsyncedLyricsPage, VersionSelector } =
await import("./pages.tsx");
const { CONFIG } = await import("./config.ts");
hooks.deregister();

const roots: Root[] = [];
Expand Down Expand Up @@ -109,6 +110,40 @@ it("keeps active scrolling lyrics sharp and caps blur on distant lines", async (
assert.equal(distant?.style.getPropertyValue("--blur-index"), "4");
});

const chineseLyrics = [
{ text: "你好", startTime: 0 },
{ text: "OK", startTime: 1000 },
];
const pinyin = [
{ startTime: 0, text: "nǐ hǎo" },
{ startTime: 1000, text: "O K" },
];
const romanizedViews = [
() => React.createElement(SyncedLyricsPage, { lyrics: chineseLyrics, romanization: pinyin }),
() => React.createElement(SyncedExpandedLyricsPage, { lyrics: chineseLyrics, romanization: pinyin }),
() => React.createElement(UnsyncedLyricsPage, { lyrics: chineseLyrics, romanization: pinyin }),
];
const romanizedLines = (container: Element) =>
[...container.querySelectorAll(".lyrics-lyricsContainer-RomanizedLine")].map((line) => line.textContent);

it("shows pinyin under its line in every lyrics view unless it repeats the line", async (t) => {
t.mock.property(CONFIG.visual, "romanization", "pinyin");
for (const view of romanizedViews) {
const container = await render(view());
assert.deepEqual(romanizedLines(container), ["nǐ hǎo"]);
const line = container.querySelector(".lyrics-lyricsContainer-RomanizedLine")?.parentElement;
assert.match(line?.textContent ?? "", /^你好/);
}
});

it("hides pinyin when romanization is turned off", async (t) => {
t.mock.property(CONFIG.visual, "romanization", "none");
for (const view of romanizedViews) {
const container = await render(view());
assert.deepEqual(romanizedLines(container), []);
}
});

it("selects Genius versions with a numeric index", async () => {
const selections: number[] = [];
const versions = [
Expand Down
Loading
Loading