From 0129d32d93d80f8d1c03b5358548ee6f7afea535 Mon Sep 17 00:00:00 2001 From: Hein Thant Maung Maung Date: Tue, 6 Oct 2026 16:21:14 +0700 Subject: [PATCH 1/2] feat(lyrics-plus): port Pinyin romanization feature --- modules/lyrics-plus/config.ts | 4 + modules/lyrics-plus/index.scss | 6 + modules/lyrics-plus/metadata.json | 2 +- modules/lyrics-plus/mod.tsx | 63 ++ modules/lyrics-plus/options-menu.tsx | 22 + modules/lyrics-plus/pages.test.mts | 37 +- modules/lyrics-plus/pages.tsx | 740 +++++++++++++----------- modules/lyrics-plus/translator.test.mts | 26 +- modules/lyrics-plus/translator.ts | 29 +- modules/lyrics-plus/types.ts | 5 + 10 files changed, 582 insertions(+), 352 deletions(-) diff --git a/modules/lyrics-plus/config.ts b/modules/lyrics-plus/config.ts index 501c06b..9f74d81 100644 --- a/modules/lyrics-plus/config.ts +++ b/modules/lyrics-plus/config.ts @@ -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", @@ -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( diff --git a/modules/lyrics-plus/index.scss b/modules/lyrics-plus/index.scss index 22f8f33..74225a3 100644 --- a/modules/lyrics-plus/index.scss +++ b/modules/lyrics-plus/index.scss @@ -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; diff --git a/modules/lyrics-plus/metadata.json b/modules/lyrics-plus/metadata.json index ec47d7e..2f22cdb 100644 --- a/modules/lyrics-plus/metadata.json +++ b/modules/lyrics-plus/metadata.json @@ -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": { diff --git a/modules/lyrics-plus/mod.tsx b/modules/lyrics-plus/mod.tsx index e3f3dcf..50c8449 100644 --- a/modules/lyrics-plus/mod.tsx +++ b/modules/lyrics-plus/mod.tsx @@ -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"; @@ -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; @@ -323,6 +325,8 @@ export class LyricsContainer extends react.Component { 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 = () => {}; @@ -368,6 +372,7 @@ export class LyricsContainer extends react.Component { isFADMode: false, isCached: false, language: null, + pinyin: null, }; this.currentTrackUri = ""; this.nextTrackUri = ""; @@ -780,6 +785,7 @@ export class LyricsContainer extends react.Component { cn: null, hk: null, tw: null, + pinyin: null, neteaseTranslation: null, ...tempState, ...translationOverrides, @@ -899,6 +905,53 @@ export class LyricsContainer extends react.Component { 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 { + const lyrics = plainLines(displayLyrics); + + client.notify("Converting...", false, 1000); + if (!this.translator) { + this.translator = new Translator("zh"); + } + const translator = this.translator; + try { + const result = await Promise.all( + lyrics.map(async (lyric) => ({ + startTime: lyric.startTime, + text: await translator.convertToPinyin(lyric.text), + })), + ); + client.notify("Converting...", false, 0); + return result; + } catch (error) { + if (!this.active) return; + client.notify("Conversion failed. Try again.", true); + console.error(error); + } } provideLanguageCode(lyrics: DisplayLyricLine[] | null) { @@ -1245,6 +1298,7 @@ export class LyricsContainer extends react.Component { this.mounted = false; ++this.requestGeneration; this._musixmatchTranslationRequestId = null; + this.romanizing = null; this.translator?.dispose(); void this.props.queries.client.cancelQueries(); sharedCallbacks.setReloadLyrics(undefined); @@ -1273,6 +1327,7 @@ export class LyricsContainer extends react.Component { ...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, }; @@ -1310,6 +1365,7 @@ export class LyricsContainer extends react.Component { ...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, }; @@ -1346,6 +1402,11 @@ export class LyricsContainer extends react.Component { 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); @@ -1371,6 +1432,7 @@ export class LyricsContainer extends react.Component { provider: this.state.provider, copyright: this.state.copyright, reRenderLyricsPage: this.reRenderLyricsPage, + romanization: romanizationLines, }, ); } else if (mode === UNSYNCED && this.state.unsynced) { @@ -1380,6 +1442,7 @@ export class LyricsContainer extends react.Component { provider: this.state.provider, copyright: this.state.copyright, reRenderLyricsPage: this.reRenderLyricsPage, + romanization: romanizationLines, }); } else if (mode === GENIUS && this.state.genius) { activeItem = react.createElement(GeniusPage, { diff --git a/modules/lyrics-plus/options-menu.tsx b/modules/lyrics-plus/options-menu.tsx index 28e2847..a6a015b 100644 --- a/modules/lyrics-plus/options-menu.tsx +++ b/modules/lyrics-plus/options-menu.tsx @@ -143,6 +143,8 @@ export const TranslationMenu = react.memo( none: "None", }; + let romanizationOptions: Record = {}; + const musixmatchDisplay = new Intl.DisplayNames(["en"], { type: "language" }); const availableMusixmatchLanguages = Array.isArray(musixmatchLanguages) ? [...new Set(musixmatchLanguages.filter(Boolean))] @@ -212,6 +214,10 @@ export const TranslationMenu = react.memo( hk: "Traditional Chinese (Hong Kong)", tw: "Traditional Chinese (Taiwan)", }; + romanizationOptions = { + none: "None", + pinyin: "Pinyin (拼音)", + }; break; } } @@ -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", @@ -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", diff --git a/modules/lyrics-plus/pages.test.mts b/modules/lyrics-plus/pages.test.mts index e61d830..6161e34 100644 --- a/modules/lyrics-plus/pages.test.mts +++ b/modules/lyrics-plus/pages.test.mts @@ -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[] = []; @@ -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 = [ diff --git a/modules/lyrics-plus/pages.tsx b/modules/lyrics-plus/pages.tsx index 54e5990..9504035 100644 --- a/modules/lyrics-plus/pages.tsx +++ b/modules/lyrics-plus/pages.tsx @@ -7,7 +7,7 @@ // to lyricContainerUpdate/reloadLyrics, so no callback plumbing lives here. import type { CSSProperties, ReactNode, ChangeEvent, MouseEvent as ReactMouseEvent } from "react"; -import type { DisplayLyricLine, RenderedLyricLine, LyricWord, GeniusVersion } from "./types.ts"; +import type { DisplayLyricLine, RenderedLyricLine, LyricWord, GeniusVersion, RomanizedLine } from "./types.ts"; import { client, React as react } from "/modules/stdlib/mod.ts"; import { CONFIG } from "./config.ts"; import { ProviderGenius } from "./providers/genius.ts"; @@ -50,6 +50,7 @@ interface LyricsPageProps extends CreditProps { lyrics: DisplayLyricLine[]; isKara?: boolean; trackUri?: string; + romanization?: RomanizedLine[] | null; } type IndicatorStyle = CSSProperties & { "--position-index"?: number; "--animation-index"?: number }; interface IdlingIndicatorProps { @@ -140,6 +141,21 @@ const isPauseLine = (text: DisplayLyricLine["text"]) => { return trimmed === "♪" || trimmed === ""; }; +const useRomanizationByTime = (romanization: RomanizedLine[] | null | undefined) => + useMemo(() => { + const byTime = new Map(); + for (const line of romanization ?? []) { + if (line.startTime != null) byTime.set(line.startTime, line.text); + } + return byTime; + }, [romanization]); + +const renderRomanizedLine = (romanizedText: string | null | undefined, lineText: unknown) => { + if (CONFIG.visual.romanization === "none" || !romanizedText) return null; + if (romanizedText.replace(/\s+/g, "") === lyricText(lineText).replace(/\s+/g, "")) return null; + return react.createElement("p", { className: "lyrics-lyricsContainer-RomanizedLine" }, romanizedText); +}; + const findNextLineStartTime = (lines: DisplayLyricLine[], fromIndex: number) => { for (let j = fromIndex + 1; j < lines.length; j++) { if (!isPauseLine(lines[j].text) && lines[j].startTime != null) { @@ -268,230 +284,238 @@ export const KaraokeLine = ({ text, isActive, position, startTime = 0, endTime } }); }; -export const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara }: LyricsPageProps) => { - const [position, setPosition] = useState(0); - const activeLineEle = useRef(null); - const lyricContainerEle = useRef(null); +export const SyncedLyricsPage = react.memo( + ({ lyrics = [], provider, copyright, isKara, romanization }: LyricsPageProps) => { + const [position, setPosition] = useState(0); + const activeLineEle = useRef(null); + const lyricContainerEle = useRef(null); + const romanizationByTime = useRomanizationByTime(romanization); + + useTrackPosition(() => { + const newPos = client.player.getProgress(); + const delay = CONFIG.visual["global-delay"] + CONFIG.visual.delay; + if (newPos !== position) { + setPosition(newPos + delay); + } + }); - useTrackPosition(() => { - const newPos = client.player.getProgress(); - const delay = CONFIG.visual["global-delay"] + CONFIG.visual.delay; - if (newPos !== position) { - setPosition(newPos + delay); - } - }); + const lyricWithEmptyLines = useMemo( + () => + [emptyLine, emptyLine, ...processPauseLines(lyrics)].map((line, i) => ({ + ...line, + lineNumber: i, + })), + [lyrics], + ); - const lyricWithEmptyLines = useMemo( - () => - [emptyLine, emptyLine, ...processPauseLines(lyrics)].map((line, i) => ({ - ...line, - lineNumber: i, - })), - [lyrics], - ); + const lyricsId = lyricText(lyrics[0]?.text); - const lyricsId = lyricText(lyrics[0]?.text); - - let activeLineIndex = 0; - for (let i = lyricWithEmptyLines.length - 1; i > 0; i--) { - if (position >= (lyricWithEmptyLines[i].startTime ?? 0)) { - // If this is a pause line and the next one starts at the same time and is NOT a pause line, - // prefer the next line (the text). - if ( - isPauseLine(lyricWithEmptyLines[i].text) && - lyricWithEmptyLines[i + 1] && - position >= (lyricWithEmptyLines[i + 1].startTime ?? 0) && - !isPauseLine(lyricWithEmptyLines[i + 1].text) - ) { - continue; + let activeLineIndex = 0; + for (let i = lyricWithEmptyLines.length - 1; i > 0; i--) { + if (position >= (lyricWithEmptyLines[i].startTime ?? 0)) { + // If this is a pause line and the next one starts at the same time and is NOT a pause line, + // prefer the next line (the text). + if ( + isPauseLine(lyricWithEmptyLines[i].text) && + lyricWithEmptyLines[i + 1] && + position >= (lyricWithEmptyLines[i + 1].startTime ?? 0) && + !isPauseLine(lyricWithEmptyLines[i + 1].text) + ) { + continue; + } + activeLineIndex = i; + break; } - activeLineIndex = i; - break; } - } - const { activeLines, activeElementIndex } = useMemo(() => { - let startIndex = activeLineIndex; - let visibleBefore = 0; - const targetBefore = Number(CONFIG.visual["lines-before"]) + 1; - while (startIndex > 0 && visibleBefore < targetBefore) { - startIndex--; - if (!isPauseLine(lyricWithEmptyLines[startIndex].text)) { - visibleBefore++; + const { activeLines, activeElementIndex } = useMemo(() => { + let startIndex = activeLineIndex; + let visibleBefore = 0; + const targetBefore = Number(CONFIG.visual["lines-before"]) + 1; + while (startIndex > 0 && visibleBefore < targetBefore) { + startIndex--; + if (!isPauseLine(lyricWithEmptyLines[startIndex].text)) { + visibleBefore++; + } } - } - let endIndex = activeLineIndex; - let visibleAfter = 0; - const targetAfter = Number(CONFIG.visual["lines-after"]) + 1; - while (endIndex < lyricWithEmptyLines.length - 1 && visibleAfter < targetAfter) { - endIndex++; - if (!isPauseLine(lyricWithEmptyLines[endIndex].text)) { - visibleAfter++; + let endIndex = activeLineIndex; + let visibleAfter = 0; + const targetAfter = Number(CONFIG.visual["lines-after"]) + 1; + while (endIndex < lyricWithEmptyLines.length - 1 && visibleAfter < targetAfter) { + endIndex++; + if (!isPauseLine(lyricWithEmptyLines[endIndex].text)) { + visibleAfter++; + } } - } - return { - activeLines: lyricWithEmptyLines.slice(startIndex, endIndex + 1), - activeElementIndex: activeLineIndex - startIndex, - }; - }, [activeLineIndex, lyricWithEmptyLines, CONFIG.visual["lines-before"], CONFIG.visual["lines-after"]]); + return { + activeLines: lyricWithEmptyLines.slice(startIndex, endIndex + 1), + activeElementIndex: activeLineIndex - startIndex, + }; + }, [activeLineIndex, lyricWithEmptyLines, CONFIG.visual["lines-before"], CONFIG.visual["lines-after"]]); - let offset = lyricContainerEle.current ? lyricContainerEle.current.clientHeight / 2 : 0; - if (activeLineEle.current) { - offset += -(activeLineEle.current.offsetTop + activeLineEle.current.clientHeight / 2); - } - const adjustedAnimationIndices: number[] = []; - let currentIndex = 0; - for (let j = activeElementIndex; j < activeLines.length; j++) { - adjustedAnimationIndices[j] = currentIndex; - if (!isPauseLine(activeLines[j].text) || j === activeElementIndex) { - currentIndex++; + let offset = lyricContainerEle.current ? lyricContainerEle.current.clientHeight / 2 : 0; + if (activeLineEle.current) { + offset += -(activeLineEle.current.offsetTop + activeLineEle.current.clientHeight / 2); } - } - currentIndex = -1; - for (let j = activeElementIndex - 1; j >= 0; j--) { - adjustedAnimationIndices[j] = currentIndex; - if (!isPauseLine(activeLines[j].text)) { - currentIndex--; + const adjustedAnimationIndices: number[] = []; + let currentIndex = 0; + for (let j = activeElementIndex; j < activeLines.length; j++) { + adjustedAnimationIndices[j] = currentIndex; + if (!isPauseLine(activeLines[j].text) || j === activeElementIndex) { + currentIndex++; + } + } + currentIndex = -1; + for (let j = activeElementIndex - 1; j >= 0; j--) { + adjustedAnimationIndices[j] = currentIndex; + if (!isPauseLine(activeLines[j].text)) { + currentIndex--; + } } - } - return react.createElement( - "div", - { - className: "lyrics-lyricsContainer-SyncedLyricsPage", - ref: lyricContainerEle, - }, - react.createElement( + return react.createElement( "div", { - className: "lyrics-lyricsContainer-SyncedLyrics", - style: { - "--offset": `${offset}px`, - }, - key: lyricsId, + className: "lyrics-lyricsContainer-SyncedLyricsPage", + ref: lyricContainerEle, }, - activeLines.map(({ text, lineNumber, startTime, endTime, originalText, performer }, i) => { - const isFocusedLine = activeElementIndex === i; - const isPause = isPauseLine(text); - - // Calculate indicator state for pause lines - const indicatorEl = getPauseIndicator( - lyricWithEmptyLines, - lineNumber, - startTime, - position, - isFocusedLine, - isPause, - ); - - let className = "lyrics-lyricsContainer-LyricsLine"; - let ref; - - const isPlaying = startTime != null && endTime != null && position >= startTime && position <= endTime; - const isActive = isFocusedLine || isPlaying; - - if (isFocusedLine) { - ref = activeLineEle; - } - if (isActive) { - className += " lyrics-lyricsContainer-LyricsLine-active"; - } else if (isPause && !indicatorEl) { - className += " lyrics-lyricsContainer-LyricsLine-hidden"; - } - - let animationIndex = adjustedAnimationIndices[i]; - - const paddingLine = - (animationIndex < 0 && -animationIndex > Number(CONFIG.visual["lines-before"])) || - animationIndex > Number(CONFIG.visual["lines-after"]); - if (paddingLine) { - className += " lyrics-lyricsContainer-LyricsLine-paddingLine"; - } - const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; - // If we have original text and we are showing translated below, we should show the original text - // Otherwise we should show the translated text - const lineText = originalText && showTranslatedBelow ? originalText : text; - - // Convert lyrics to text for comparison - const belowOrigin = lyricText(originalText).replace(/\s+/g, ""); - const belowTxt = lyricText(text).replace(/\s+/g, ""); - const belowMode = showTranslatedBelow && Boolean(originalText) && belowOrigin !== belowTxt; - - return react.createElement( - "div", - { - className, - style: { - cursor: "pointer", - "--position-index": animationIndex, - "--animation-index": (animationIndex < 0 ? 0 : animationIndex) + 1, - "--blur-index": Math.abs(animationIndex), - }, - dir: "auto", - ref, - key: lineNumber, - onClick: () => { - if (startTime) { - client.player.seek(startTime); - } - }, + react.createElement( + "div", + { + className: "lyrics-lyricsContainer-SyncedLyrics", + style: { + "--offset": `${offset}px`, }, - isPause - ? indicatorEl - : react.createElement( + key: lyricsId, + }, + activeLines.map(({ text, lineNumber, startTime, endTime, originalText, performer }, i) => { + const isFocusedLine = activeElementIndex === i; + const isPause = isPauseLine(text); + + // Calculate indicator state for pause lines + const indicatorEl = getPauseIndicator( + lyricWithEmptyLines, + lineNumber, + startTime, + position, + isFocusedLine, + isPause, + ); + + let className = "lyrics-lyricsContainer-LyricsLine"; + let ref; + + const isPlaying = + startTime != null && endTime != null && position >= startTime && position <= endTime; + const isActive = isFocusedLine || isPlaying; + + if (isFocusedLine) { + ref = activeLineEle; + } + if (isActive) { + className += " lyrics-lyricsContainer-LyricsLine-active"; + } else if (isPause && !indicatorEl) { + className += " lyrics-lyricsContainer-LyricsLine-hidden"; + } + + let animationIndex = adjustedAnimationIndices[i]; + + const paddingLine = + (animationIndex < 0 && -animationIndex > Number(CONFIG.visual["lines-before"])) || + animationIndex > Number(CONFIG.visual["lines-after"]); + if (paddingLine) { + className += " lyrics-lyricsContainer-LyricsLine-paddingLine"; + } + const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; + // If we have original text and we are showing translated below, we should show the original text + // Otherwise we should show the translated text + const lineText = originalText && showTranslatedBelow ? originalText : text; + + // Convert lyrics to text for comparison + const belowOrigin = lyricText(originalText).replace(/\s+/g, ""); + const belowTxt = lyricText(text).replace(/\s+/g, ""); + const belowMode = showTranslatedBelow && Boolean(originalText) && belowOrigin !== belowTxt; + + return react.createElement( + "div", + { + className, + style: { + cursor: "pointer", + "--position-index": animationIndex, + "--animation-index": (animationIndex < 0 ? 0 : animationIndex) + 1, + "--blur-index": Math.abs(animationIndex), + }, + dir: "auto", + ref, + key: lineNumber, + onClick: () => { + if (startTime) { + client.player.seek(startTime); + } + }, + }, + isPause + ? indicatorEl + : react.createElement( + "p", + { + onContextMenu: (event: ReactMouseEvent) => { + event.preventDefault(); + client.platform.ClipboardAPI.copy( + convertParsedToLRC(lyrics, belowMode).original, + ) + .then(() => client.notify("Lyrics copied to clipboard")) + .catch(() => client.notify("Failed to copy lyrics to clipboard")); + }, + }, + renderPerformer( + performer, + lyricWithEmptyLines[lineNumber - 1]?.performer, + CONFIG.visual["synced-compact"], + ), + !(isKara && isKaraokeWords(text)) + ? renderLineText(lineText) + : react.createElement(KaraokeLine, { + text, + startTime, + endTime, + position, + isActive, + }), + ), + belowMode && + react.createElement( "p", { + style: { + opacity: 0.5, + }, onContextMenu: (event: ReactMouseEvent) => { event.preventDefault(); - client.platform.ClipboardAPI.copy( - convertParsedToLRC(lyrics, belowMode).original, - ) - .then(() => client.notify("Lyrics copied to clipboard")) - .catch(() => client.notify("Failed to copy lyrics to clipboard")); + client.platform.ClipboardAPI.copy(convertParsedToLRC(lyrics, belowMode).conver) + .then(() => client.notify("Translated lyrics copied to clipboard")) + .catch(() => + client.notify("Failed to copy translated lyrics to clipboard"), + ); }, }, - renderPerformer( - performer, - lyricWithEmptyLines[lineNumber - 1]?.performer, - CONFIG.visual["synced-compact"], - ), - !(isKara && isKaraokeWords(text)) - ? renderLineText(lineText) - : react.createElement(KaraokeLine, { - text, - startTime, - endTime, - position, - isActive, - }), + renderLineText(text), ), - belowMode && - react.createElement( - "p", - { - style: { - opacity: 0.5, - }, - onContextMenu: (event: ReactMouseEvent) => { - event.preventDefault(); - client.platform.ClipboardAPI.copy(convertParsedToLRC(lyrics, belowMode).conver) - .then(() => client.notify("Translated lyrics copied to clipboard")) - .catch(() => client.notify("Failed to copy translated lyrics to clipboard")); - }, - }, - renderLineText(text), - ), - ); + !isPause && + renderRomanizedLine(startTime != null ? romanizationByTime.get(startTime) : null, lineText), + ); + }), + ), + react.createElement(CreditFooter, { + provider, + copyright, }), - ), - react.createElement(CreditFooter, { - provider, - copyright, - }), - ); -}); + ); + }, +); interface SearchBarState { hidden: boolean; @@ -635,183 +659,202 @@ function isInViewport(element: Element) { ); } -export const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKara }: LyricsPageProps) => { - const [position, setPosition] = useState( - () => client.player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay, - ); - const activeLineRef = useRef(null); - const pageRef = useRef(null); +export const SyncedExpandedLyricsPage = react.memo( + ({ lyrics, provider, copyright, isKara, romanization }: LyricsPageProps) => { + const [position, setPosition] = useState( + () => client.player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay, + ); + const activeLineRef = useRef(null); + const pageRef = useRef(null); + const romanizationByTime = useRomanizationByTime(romanization); - useTrackPosition(() => { - if (client.player.isPlaying()) { - setPosition(client.player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay); - } - }); + useTrackPosition(() => { + if (client.player.isPlaying()) { + setPosition(client.player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay); + } + }); - const padded = useMemo(() => [emptyLine, ...processPauseLines(lyrics)], [lyrics]); + const padded = useMemo(() => [emptyLine, ...processPauseLines(lyrics)], [lyrics]); - const initialScroll = useRef(true); + const initialScroll = useRef(true); - // Reset scroll state when lyrics change - useEffect(() => { - initialScroll.current = true; - }, [lyrics]); - - const lyricsId = lyricText(lyrics[0]?.text); - - let activeLineIndex = 0; - for (let i = padded.length - 1; i >= 0; i--) { - const line = padded[i]; - if (position >= (line.startTime ?? 0)) { - // If this is a pause line and the next one starts at the same time and is NOT a pause line, - // prefer the next line (the text). - if ( - isPauseLine(line.text) && - padded[i + 1] && - position >= (padded[i + 1].startTime ?? 0) && - !isPauseLine(padded[i + 1].text) - ) { - continue; - } - activeLineIndex = i; - break; - } - } + // Reset scroll state when lyrics change + useEffect(() => { + initialScroll.current = true; + }, [lyrics]); - useEffect(() => { - if (activeLineRef.current && (initialScroll.current || isInViewport(activeLineRef.current))) { - // Ignore focus on the first "empty" idling indicator if it's during initial load - if (initialScroll.current && activeLineIndex === 0) { - const nextStart = findNextLineStartTime(padded, 0); - // If the intro is very short (e.g. less than 300ms), don't focus it - if (nextStart && nextStart - position < 300) { - initialScroll.current = false; - return; + const lyricsId = lyricText(lyrics[0]?.text); + + let activeLineIndex = 0; + for (let i = padded.length - 1; i >= 0; i--) { + const line = padded[i]; + if (position >= (line.startTime ?? 0)) { + // If this is a pause line and the next one starts at the same time and is NOT a pause line, + // prefer the next line (the text). + if ( + isPauseLine(line.text) && + padded[i + 1] && + position >= (padded[i + 1].startTime ?? 0) && + !isPauseLine(padded[i + 1].text) + ) { + continue; } + activeLineIndex = i; + break; } - - activeLineRef.current.scrollIntoView({ - behavior: initialScroll.current ? "auto" : "smooth", - block: "center", - inline: "nearest", - }); - initialScroll.current = false; } - }, [activeLineIndex, lyricsId]); - return react.createElement( - "div", - { - className: "lyrics-lyricsContainer-UnsyncedLyricsPage lyrics-expanded-synced", - key: lyricsId, - ref: pageRef, - }, - react.createElement("p", { - className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", - }), - padded.map(({ text, startTime, endTime, originalText, performer }, i) => { - // Show idling indicator for the initial empty line - if (i === 0) { - const nextStart = findNextLineStartTime(padded, 0); - return react.createElement(IdlingIndicator, { - key: i, - isActive: activeLineIndex === 0, - progress: nextStart ? position / nextStart : 0, - delay: nextStart ? nextStart / 3 : 0, - className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active", - style: { "--position-index": 0, "--animation-index": 1 }, + useEffect(() => { + if (activeLineRef.current && (initialScroll.current || isInViewport(activeLineRef.current))) { + // Ignore focus on the first "empty" idling indicator if it's during initial load + if (initialScroll.current && activeLineIndex === 0) { + const nextStart = findNextLineStartTime(padded, 0); + // If the intro is very short (e.g. less than 300ms), don't focus it + if (nextStart && nextStart - position < 300) { + initialScroll.current = false; + return; + } + } + + activeLineRef.current.scrollIntoView({ + behavior: initialScroll.current ? "auto" : "smooth", + block: "center", + inline: "nearest", }); + initialScroll.current = false; } + }, [activeLineIndex, lyricsId]); + + return react.createElement( + "div", + { + className: "lyrics-lyricsContainer-UnsyncedLyricsPage lyrics-expanded-synced", + key: lyricsId, + ref: pageRef, + }, + react.createElement("p", { + className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", + }), + padded.map(({ text, startTime, endTime, originalText, performer }, i) => { + // Show idling indicator for the initial empty line + if (i === 0) { + const nextStart = findNextLineStartTime(padded, 0); + return react.createElement(IdlingIndicator, { + key: i, + isActive: activeLineIndex === 0, + progress: nextStart ? position / nextStart : 0, + delay: nextStart ? nextStart / 3 : 0, + className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active", + style: { "--position-index": 0, "--animation-index": 1 }, + }); + } - const isFocused = i === activeLineIndex; - const isPause = isPauseLine(text); + const isFocused = i === activeLineIndex; + const isPause = isPauseLine(text); - // Calculate indicator state for pause lines - const indicatorEl = getPauseIndicator(padded, i, startTime, position, isFocused, isPause); + // Calculate indicator state for pause lines + const indicatorEl = getPauseIndicator(padded, i, startTime, position, isFocused, isPause); - const isPlaying = startTime != null && endTime != null && position >= startTime && position <= endTime; - const isPast = - (endTime != null && position > endTime) || (!isFocused && startTime != null && position > startTime); - const isActive = isFocused || isPlaying; + const isPlaying = startTime != null && endTime != null && position >= startTime && position <= endTime; + const isPast = + (endTime != null && position > endTime) || + (!isFocused && startTime != null && position > startTime); + const isActive = isFocused || isPlaying; - let className = `lyrics-lyricsContainer-LyricsLine${isActive ? " lyrics-lyricsContainer-LyricsLine-active" : ""}${isPast ? " lyrics-lyricsContainer-LyricsLine-past" : ""}`; - if (isPause && !indicatorEl) { - className += " lyrics-lyricsContainer-LyricsLine-hidden"; - } + let className = `lyrics-lyricsContainer-LyricsLine${isActive ? " lyrics-lyricsContainer-LyricsLine-active" : ""}${isPast ? " lyrics-lyricsContainer-LyricsLine-past" : ""}`; + if (isPause && !indicatorEl) { + className += " lyrics-lyricsContainer-LyricsLine-hidden"; + } - const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; - // If we have original text and we are showing translated below, we should show the original text - // Otherwise we should show the translated text - const lineText = originalText && showTranslatedBelow ? originalText : text; + const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; + // If we have original text and we are showing translated below, we should show the original text + // Otherwise we should show the translated text + const lineText = originalText && showTranslatedBelow ? originalText : text; - // Convert lyrics to text for comparison - const belowOrigin = lyricText(originalText).replace(/\s+/g, ""); - const belowTxt = lyricText(text).replace(/\s+/g, ""); - const belowMode = showTranslatedBelow && Boolean(originalText) && belowOrigin !== belowTxt; + // Convert lyrics to text for comparison + const belowOrigin = lyricText(originalText).replace(/\s+/g, ""); + const belowTxt = lyricText(text).replace(/\s+/g, ""); + const belowMode = showTranslatedBelow && Boolean(originalText) && belowOrigin !== belowTxt; - return react.createElement( - "div", - { - className, - key: i, - style: { - cursor: "pointer", - "--blur-index": isActive ? 0 : Math.min(Math.abs(i - activeLineIndex), 4), - }, - dir: "auto", - ref: isFocused ? activeLineRef : null, - onClick: () => { - if (startTime) { - client.player.seek(startTime); - } + return react.createElement( + "div", + { + className, + key: i, + style: { + cursor: "pointer", + "--blur-index": isActive ? 0 : Math.min(Math.abs(i - activeLineIndex), 4), + }, + dir: "auto", + ref: isFocused ? activeLineRef : null, + onClick: () => { + if (startTime) { + client.player.seek(startTime); + } + }, }, - }, - isPause - ? indicatorEl - : react.createElement( + isPause + ? indicatorEl + : react.createElement( + "p", + { + onContextMenu: (event: ReactMouseEvent) => { + event.preventDefault(); + client.platform.ClipboardAPI.copy( + convertParsedToLRC(lyrics, belowMode).original, + ) + .then(() => client.notify("Lyrics copied to clipboard")) + .catch(() => client.notify("Failed to copy lyrics to clipboard")); + }, + }, + renderPerformer(performer, padded[i - 1]?.performer, CONFIG.visual["synced-compact"]), + !(isKara && isKaraokeWords(text)) + ? renderLineText(lineText) + : react.createElement(KaraokeLine, { + text, + startTime, + endTime, + position, + isActive, + }), + ), + belowMode && + react.createElement( "p", { + style: { opacity: 0.5 }, onContextMenu: (event: ReactMouseEvent) => { event.preventDefault(); - client.platform.ClipboardAPI.copy(convertParsedToLRC(lyrics, belowMode).original) - .then(() => client.notify("Lyrics copied to clipboard")) - .catch(() => client.notify("Failed to copy lyrics to clipboard")); + client.platform.ClipboardAPI.copy(convertParsedToLRC(lyrics, belowMode).conver) + .then(() => client.notify("Translated lyrics copied to clipboard")) + .catch(() => client.notify("Failed to copy translated lyrics to clipboard")); }, }, - renderPerformer(performer, padded[i - 1]?.performer, CONFIG.visual["synced-compact"]), - !(isKara && isKaraokeWords(text)) - ? renderLineText(lineText) - : react.createElement(KaraokeLine, { text, startTime, endTime, position, isActive }), + renderLineText(text), ), - belowMode && - react.createElement( - "p", - { - style: { opacity: 0.5 }, - onContextMenu: (event: ReactMouseEvent) => { - event.preventDefault(); - client.platform.ClipboardAPI.copy(convertParsedToLRC(lyrics, belowMode).conver) - .then(() => client.notify("Translated lyrics copied to clipboard")) - .catch(() => client.notify("Failed to copy translated lyrics to clipboard")); - }, - }, - renderLineText(text), - ), - ); - }), - react.createElement("p", { - className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", - }), - react.createElement(CreditFooter, { - provider, - copyright, - }), - react.createElement(SearchBar, null), - ); -}); + !isPause && + renderRomanizedLine(startTime != null ? romanizationByTime.get(startTime) : null, lineText), + ); + }), + react.createElement("p", { + className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", + }), + react.createElement(CreditFooter, { + provider, + copyright, + }), + react.createElement(SearchBar, null), + ); + }, +); export const UnsyncedLyricsPage = react.memo( - ({ lyrics, provider, copyright }: CreditProps & { lyrics: RenderedLyricLine[]; trackUri?: string }) => { + ({ + lyrics, + provider, + copyright, + romanization, + }: CreditProps & { lyrics: RenderedLyricLine[]; trackUri?: string; romanization?: RomanizedLine[] | null }) => { return react.createElement( "div", { @@ -865,6 +908,7 @@ export const UnsyncedLyricsPage = react.memo( }, renderLineText(text), ), + renderRomanizedLine(romanization?.[index]?.text, lineText), ); }), react.createElement("p", { diff --git a/modules/lyrics-plus/translator.test.mts b/modules/lyrics-plus/translator.test.mts index 6985e2c..1382688 100644 --- a/modules/lyrics-plus/translator.test.mts +++ b/modules/lyrics-plus/translator.test.mts @@ -1,6 +1,7 @@ import "../stdlib/lib/test-setup.mts"; import assert from "node:assert/strict"; import { test, type TestContext } from "node:test"; +import { CONFIG } from "./config.ts"; import { Translator } from "./translator.ts"; Object.defineProperty(globalThis, "XMLHttpRequest", { value: window.XMLHttpRequest, configurable: true }); @@ -17,7 +18,7 @@ function setupLoading(t: TestContext) { return node; }); t.after(() => { - for (const key of ["Kuroshiro", "KuromojiAnalyzer", "Aromanize", "OpenCC"]) { + for (const key of ["Kuroshiro", "KuromojiAnalyzer", "Aromanize", "OpenCC", "pinyinPro"]) { Reflect.deleteProperty(globalThis, key); } }); @@ -176,6 +177,29 @@ test("a stalled Japanese conversion times out and disposal cancels another conve assertNoTimers(); }); +test("pinyin loads while conversion is disabled and stops after dispose", async (t) => { + const { scripts, assertNoTimers } = setupLoading(t); + t.mock.property(CONFIG.visual, "translate", false); + const translator = new Translator("zh", false, { timeoutMs: 25 }); + const converted = translator.convertToPinyin("你好"); + assert.equal(scripts.length, 1); + assert.match(scripts[0].src, /pinyin-pro@3\.28\.1/); + Object.defineProperty(globalThis, "pinyinPro", { + value: { + pinyin: (text: string, { toneType, nonZh }: { toneType: string; nonZh: string }) => + `${toneType}:${nonZh}:${text}`, + }, + configurable: true, + }); + scripts[0].dispatchEvent(new Event("load")); + assert.equal(await converted, "symbol:consecutive:你好"); + assert.equal(await translator.convertToPinyin("字"), "symbol:consecutive:字"); + assert.equal(scripts.length, 1); + translator.dispose(); + await assert.rejects(translator.convertToPinyin("字"), { name: "AbortError" }); + assertNoTimers(); +}); + test("dispose cancels all waiters and removes pending script listeners and timers", async (t) => { const { scripts, assertNoTimers } = setupLoading(t); const translator = new Translator("ko", true, { timeoutMs: 25 }); diff --git a/modules/lyrics-plus/translator.ts b/modules/lyrics-plus/translator.ts index 87b7154..1ebeee1 100644 --- a/modules/lyrics-plus/translator.ts +++ b/modules/lyrics-plus/translator.ts @@ -14,6 +14,7 @@ declare const Kuroshiro: { default: new () => JapaneseTranslator }; declare const KuromojiAnalyzer: new (options: { dictPath: string }) => unknown; declare const Aromanize: { hangulToLatin(text: string, mode: string): string }; declare const OpenCC: { Converter(options: { from: string; to: string }): (text: string) => string }; +declare const pinyinPro: { pinyin(text: string, options: { toneType: string; nonZh: string }): string }; declare global { interface XMLHttpRequest { @@ -26,6 +27,7 @@ const kuromojiPath = "https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js"; const aromanize = "https://cdn.jsdelivr.net/npm/aromanize@0.1.5/aromanize.min.js"; const openCCPath = "https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/dist/umd/full.min.js"; +const pinyinProPath = "https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.1/dist/index.min.js"; const dictPath = "https:/cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict"; @@ -36,13 +38,14 @@ export interface TranslatorOptions { } export class Translator { - private readonly loading = new Map>(); + private readonly loading = new Map>(); private readonly lifecycle = new AbortController(); private readonly timeoutMs: number; private readonly isUsingNetease: boolean; private kuroshiro?: JapaneseTranslator; private Aromanize?: typeof Aromanize; private OpenCC?: typeof OpenCC; + private pinyinPro?: typeof pinyinPro; constructor(lang: string, isUsingNetease = false, options: TranslatorOptions = {}) { this.isUsingNetease = isUsingNetease; @@ -57,6 +60,7 @@ export class Translator { this.kuroshiro = undefined; this.Aromanize = undefined; this.OpenCC = undefined; + this.pinyinPro = undefined; } private bounded(operation: (signal: AbortSignal) => Promise): Promise { @@ -170,6 +174,23 @@ export class Translator { } } + // Romanization works without the Convert toggle, so pinyin loads outside awaitFinished's gate. + private awaitPinyin(): Promise { + if (this.lifecycle.signal.aborted) return Promise.reject(this.lifecycle.signal.reason); + if (this.pinyinPro) return Promise.resolve(); + const pending = this.loading.get("pinyin"); + if (pending) return pending; + const loading = this.bounded(async (signal) => { + if (typeof pinyinPro === "undefined") + await this.loadScript(pinyinProPath, signal, () => typeof pinyinPro !== "undefined"); + signal.throwIfAborted(); + if (typeof pinyinPro === "undefined") throw new Error("Pinyin converter did not load"); + this.pinyinPro = pinyinPro; + }).finally(() => this.loading.delete("pinyin")); + this.loading.set("pinyin", loading); + return loading; + } + /** * Fix an issue with kuromoji when loading dict from external urls * Adapted from: https://github.com/mobilusoss/textlint-browser-runner/pull/7 @@ -212,4 +233,10 @@ export class Translator { if (!this.OpenCC) throw this.lifecycle.signal.reason; return this.OpenCC.Converter({ from, to: target })(text); } + + async convertToPinyin(text: string): Promise { + await this.awaitPinyin(); + if (!this.pinyinPro) throw this.lifecycle.signal.reason; + return this.pinyinPro.pinyin(text, { toneType: "symbol", nonZh: "consecutive" }); + } } diff --git a/modules/lyrics-plus/types.ts b/modules/lyrics-plus/types.ts index a699644..7ecefc6 100644 --- a/modules/lyrics-plus/types.ts +++ b/modules/lyrics-plus/types.ts @@ -47,6 +47,11 @@ export type TranslationMode = | "tw"; export type LyricsLanguage = "ja" | "ko" | "zh-hans" | "zh-hant"; +export interface RomanizedLine { + startTime?: number; + text: string; +} + export function isTranslationMode(value: unknown): value is TranslationMode { return ["romaji", "furigana", "hiragana", "katakana", "hangul", "romaja", "cn", "hk", "tw"].some( (mode) => mode === value, From beb6474c0a89d24d8b5ad1b654350fc33f212a04 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Sat, 10 Oct 2026 01:37:53 +0200 Subject: [PATCH 2/2] fix(lyrics-plus): romanize without a toast on every track --- .../lyrics-plus/container-requests.test.mts | 41 ++++++++++++++++++- modules/lyrics-plus/mod.tsx | 9 ++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/modules/lyrics-plus/container-requests.test.mts b/modules/lyrics-plus/container-requests.test.mts index daf2587..0af4d20 100644 --- a/modules/lyrics-plus/container-requests.test.mts +++ b/modules/lyrics-plus/container-requests.test.mts @@ -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 }; @@ -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; @@ -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; diff --git a/modules/lyrics-plus/mod.tsx b/modules/lyrics-plus/mod.tsx index 50c8449..ec22578 100644 --- a/modules/lyrics-plus/mod.tsx +++ b/modules/lyrics-plus/mod.tsx @@ -933,23 +933,22 @@ export class LyricsContainer extends react.Component { async romanizeLyrics(displayLyrics: DisplayLyricLine[]): Promise { const lyrics = plainLines(displayLyrics); - client.notify("Converting...", false, 1000); + // 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 { - const result = await Promise.all( + return await Promise.all( lyrics.map(async (lyric) => ({ startTime: lyric.startTime, text: await translator.convertToPinyin(lyric.text), })), ); - client.notify("Converting...", false, 0); - return result; } catch (error) { if (!this.active) return; - client.notify("Conversion failed. Try again.", true); + client.notify("Couldn't load pinyin for these lyrics", true); console.error(error); } }