From a0ac2cd2b3054a3202d52d01d6d2254dbf51443e Mon Sep 17 00:00:00 2001 From: Hein Thant Maung Maung Date: Mon, 20 Jul 2026 14:09:55 +0700 Subject: [PATCH 1/2] feat(lyrics-plus): implement pinyin romanization for Chinese lyrics --- CustomApps/lyrics-plus/OptionsMenu.js | 23 +++++++++ CustomApps/lyrics-plus/Pages.js | 67 +++++++++++++++++++++++++-- CustomApps/lyrics-plus/Translator.js | 34 +++++++++++--- CustomApps/lyrics-plus/index.js | 59 +++++++++++++++++++++++ 4 files changed, 174 insertions(+), 9 deletions(-) diff --git a/CustomApps/lyrics-plus/OptionsMenu.js b/CustomApps/lyrics-plus/OptionsMenu.js index ee07ec227f..5204a0ba62 100644 --- a/CustomApps/lyrics-plus/OptionsMenu.js +++ b/CustomApps/lyrics-plus/OptionsMenu.js @@ -142,6 +142,8 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation, musixmat none: "None", }; + let romanizationOptions = {}; + const musixmatchDisplay = new Intl.DisplayNames(["en"], { type: "language" }); const availableMusixmatchLanguages = Array.isArray(musixmatchLanguages) ? [...new Set(musixmatchLanguages.filter(Boolean))] : []; const activeMusixmatchLanguage = musixmatchSelectedLanguage && musixmatchSelectedLanguage !== "none" ? musixmatchSelectedLanguage : null; @@ -205,6 +207,10 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation, musixmat hk: "Traditional Chinese (Hong Kong)", tw: "Traditional Chinese (Taiwan)", }; + romanizationOptions = { + none: "None", + pinyin: "Pinyin (拼音)", + }; break; } } @@ -242,6 +248,15 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation, musixmat // for songs in languages that support translation but not Convert (e.g., English), the option is disabled. when: () => friendlyLanguage, }, + { + desc: "Romanization", + key: "romanization", + type: ConfigSelection, + options: romanizationOptions, + renderInline: true, + // only Chinese has a romanization option (Pinyin) for now. + when: () => friendlyLanguage === "chinese", + }, { desc: "Convert", key: "translate", @@ -379,6 +394,14 @@ const AdjustmentsMenu = react.memo(({ mode, hasPerformer }) => { max: fontSizeLimit.max, step: fontSizeLimit.step, }, + { + desc: "Romanization font size", + key: "romanization-font-size", + type: ConfigAdjust, + min: fontSizeLimit.min, + max: fontSizeLimit.max, + step: fontSizeLimit.step, + }, { desc: "Track delay", key: "delay", diff --git a/CustomApps/lyrics-plus/Pages.js b/CustomApps/lyrics-plus/Pages.js index ccbf74cf88..5571724b70 100755 --- a/CustomApps/lyrics-plus/Pages.js +++ b/CustomApps/lyrics-plus/Pages.js @@ -170,7 +170,7 @@ const KaraokeLine = ({ text, isActive, position, startTime, endTime }) => { }); }; -const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara }) => { +const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara, romanization }) => { const [position, setPosition] = useState(0); const activeLineEle = useRef(); const lyricContainerEle = useRef(); @@ -183,6 +183,17 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara } }); + const romanizationByTime = useMemo(() => { + if (!romanization) return null; + const map = {}; + for (const line of romanization) { + if (line.startTime != null) { + map[line.startTime] = line.text; + } + } + return map; + }, [romanization]); + const lyricWithEmptyLines = useMemo( () => [emptyLine, emptyLine, ...processPauseLines(lyrics)].map((line, i) => ({ @@ -319,6 +330,10 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt; + const romanizedText = CONFIG.visual["romanization"] !== "none" && !isPause ? romanizationByTime?.[startTime] : null; + const lineTextStr = (typeof lineText === "object" ? lineText?.props?.children?.[0] : lineText) ?? ""; + const showRomanizedLine = romanizedText && romanizedText.replace(/\s+/g, "") !== String(lineTextStr).replace(/\s+/g, ""); + return react.createElement( "div", { @@ -368,6 +383,17 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara }, }, text + ), + showRomanizedLine && + react.createElement( + "p", + { + style: { + opacity: 0.5, + fontSize: "var(--lyrics-romanization-font-size)", + }, + }, + romanizedText ) ); }) @@ -536,11 +562,22 @@ function isInViewport(element) { ); } -const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKara }) => { +const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKara, romanization }) => { const [position, setPosition] = useState(() => Spicetify.Player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay); const activeLineRef = useRef(null); const pageRef = useRef(null); + const romanizationByTime = useMemo(() => { + if (!romanization) return null; + const map = {}; + for (const line of romanization) { + if (line.startTime != null) { + map[line.startTime] = line.text; + } + } + return map; + }, [romanization]); + useTrackPosition(() => { if (!Spicetify.Player.data.is_paused) { setPosition(Spicetify.Player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay); @@ -648,6 +685,10 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt; + const romanizedText = CONFIG.visual["romanization"] !== "none" && !isPause ? romanizationByTime?.[startTime] : null; + const lineTextStr = (typeof lineText === "object" ? lineText?.props?.children?.[0] : lineText) ?? ""; + const showRomanizedLine = romanizedText && romanizedText.replace(/\s+/g, "") !== String(lineTextStr).replace(/\s+/g, ""); + return react.createElement( "div", { @@ -692,6 +733,14 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa }, }, text + ), + showRomanizedLine && + react.createElement( + "p", + { + style: { opacity: 0.5, fontSize: "var(--lyrics-romanization-font-size)" }, + }, + romanizedText ) ); }), @@ -706,7 +755,7 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa ); }); -const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { +const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright, romanization }) => { return react.createElement( "div", { @@ -732,6 +781,10 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt; + const romanizedText = CONFIG.visual["romanization"] !== "none" ? romanization?.[index]?.text : null; + const lineTextStr = (typeof lineText === "object" ? lineText?.props?.children?.[0] : lineText) ?? ""; + const showRomanizedLine = romanizedText && romanizedText.replace(/\s+/g, "") !== String(lineTextStr).replace(/\s+/g, ""); + return react.createElement( "div", { @@ -765,6 +818,14 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { }, }, text + ), + showRomanizedLine && + react.createElement( + "p", + { + style: { opacity: 0.5, fontSize: "var(--lyrics-romanization-font-size)" }, + }, + romanizedText ) ); }), diff --git a/CustomApps/lyrics-plus/Translator.js b/CustomApps/lyrics-plus/Translator.js index 453c0f3d73..eb0f2ac07d 100644 --- a/CustomApps/lyrics-plus/Translator.js +++ b/CustomApps/lyrics-plus/Translator.js @@ -2,6 +2,7 @@ const kuroshiroPath = "https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshi 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"; @@ -19,8 +20,8 @@ class Translator { this.createTranslator(lang); } - includeExternal(url) { - if ((CONFIG.visual.translate || this.isUsingNetease) && !document.querySelector(`script[src="${url}"]`)) { + includeExternal(url, forceInclude = false) { + if ((forceInclude || CONFIG.visual.translate || this.isUsingNetease) && !document.querySelector(`script[src="${url}"]`)) { const script = document.createElement("script"); script.setAttribute("type", "text/javascript"); script.setAttribute("src", url); @@ -28,6 +29,10 @@ class Translator { } } + isUsingRomanization() { + return CONFIG.visual["romanization"] && CONFIG.visual["romanization"] !== "none"; + } + injectExternals(lang) { switch (lang?.slice(0, 2)) { case "ja": @@ -39,6 +44,7 @@ class Translator { break; case "zh": this.includeExternal(openCCPath); + this.includeExternal(pinyinProPath, this.isUsingRomanization()); break; } } @@ -101,16 +107,23 @@ class Translator { this.Aromanize = Aromanize; this.finished.ko = true; break; - case "zh": - if (this.OpenCC) return; - if (typeof OpenCC === "undefined") { + case "zh": { + const needsOpenCC = CONFIG.visual.translate || this.isUsingNetease; + const needsPinyin = this.isUsingRomanization(); + if (needsOpenCC && typeof OpenCC === "undefined") { + await Translator.#sleep(50); + return this.createTranslator(lang); + } + if (needsPinyin && typeof pinyinPro === "undefined") { await Translator.#sleep(50); return this.createTranslator(lang); } - this.OpenCC = OpenCC; + if (needsOpenCC && !this.OpenCC) this.OpenCC = OpenCC; + if (needsPinyin && !this.pinyinPro) this.pinyinPro = pinyinPro; this.finished.zh = true; break; + } } } @@ -150,6 +163,15 @@ class Translator { return converter(text); } + async convertToPinyin(text) { + if (!this.finished.zh || !this.pinyinPro) { + await Translator.#sleep(100); + return this.convertToPinyin(text); + } + + return this.pinyinPro.pinyin(text, { toneType: "symbol", nonZh: "consecutive" }); + } + /** * Async wrapper of `setTimeout`. * diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js index 1e139bb45e..82c7ad71c0 100644 --- a/CustomApps/lyrics-plus/index.js +++ b/CustomApps/lyrics-plus/index.js @@ -51,12 +51,14 @@ const CONFIG = { "lines-before": localStorage.getItem("lyrics-plus:visual:lines-before") || "0", "lines-after": localStorage.getItem("lyrics-plus:visual:lines-after") || "2", "font-size": localStorage.getItem("lyrics-plus:visual:font-size") || "32", + "romanization-font-size": 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", "translate:detect-language-override": localStorage.getItem("lyrics-plus:visual:translate:detect-language-override") || "off", "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": localStorage.getItem("lyrics-plus:visual:ja-detect-threshold") || "40", "hans-detect-threshold": localStorage.getItem("lyrics-plus:visual:hans-detect-threshold") || "40", @@ -121,6 +123,7 @@ CONFIG.locked = Number.parseInt(CONFIG.locked); CONFIG.visual["lines-before"] = Number.parseInt(CONFIG.visual["lines-before"]); CONFIG.visual["lines-after"] = Number.parseInt(CONFIG.visual["lines-after"]); CONFIG.visual["font-size"] = Number.parseInt(CONFIG.visual["font-size"]); +CONFIG.visual["romanization-font-size"] = Number.parseInt(CONFIG.visual["romanization-font-size"]); CONFIG.visual["ja-detect-threshold"] = Number.parseInt(CONFIG.visual["ja-detect-threshold"]); CONFIG.visual["hans-detect-threshold"] = Number.parseInt(CONFIG.visual["hans-detect-threshold"]); @@ -206,6 +209,7 @@ class LyricsContainer extends react.Component { cn: null, hk: null, tw: null, + pinyin: null, musixmatchTranslation: null, musixmatchTranslationLanguage: null, musixmatchAvailableTranslations: [], @@ -246,6 +250,7 @@ class LyricsContainer extends react.Component { this.displayMode = null; this.currentMusixmatchLanguage = CONFIG.visual["musixmatch-translation-language"]; this._musixmatchTranslationRequestId = null; + this._romanizing = false; } infoFromTrack(track) { @@ -505,6 +510,7 @@ class LyricsContainer extends react.Component { this.fetchTempo(info.uri); this.resetDelay(); + this._romanizing = false; let tempState; // if lyrics are cached @@ -612,6 +618,7 @@ class LyricsContainer extends react.Component { cn: null, hk: null, tw: null, + pinyin: null, neteaseTranslation: null, ...tempState, ...translationOverrides, @@ -695,6 +702,28 @@ class LyricsContainer extends react.Component { CACHE[lyricsState.uri] = { ...CACHE[lyricsState.uri], ...resetCache }; } } + + const canRomanize = + CONFIG.visual["romanization"] !== "none" && + (lang === "zh-hans" || lang === "zh-hant") && + (mode === SYNCED || mode === UNSYNCED) && + Array.isArray(lyrics) && + lyrics.length; + + if (canRomanize) { + if ((!this.state.pinyin || this.state.pinyin.mode !== mode) && !this._romanizing) { + this._romanizing = true; + const uri = lyricsState.uri; + this.romanizeLyrics(lyrics).then((lines) => { + this._romanizing = false; + if (lines && uri === this.state.uri) { + this.setState({ pinyin: { mode, lines } }); + } + }); + } + } else if (this.state.pinyin) { + this.state.pinyin = null; + } } provideLanguageCode(lyrics) { @@ -780,6 +809,31 @@ class LyricsContainer extends react.Component { } } + async romanizeLyrics(lyrics) { + if (!lyrics) return null; + + Spicetify.showNotification("Converting...", false, 1000); + if (!this.translator) { + this.translator = new Translator("zh"); + } + await this.translator.awaitFinished("zh"); + + try { + const result = await Promise.all( + lyrics.map(async (lyric) => ({ + startTime: lyric.startTime, + text: await this.translator.convertToPinyin(lyric.text), + })) + ); + Spicetify.showNotification("Converting...", false, 0); + return result; + } catch (error) { + Spicetify.showNotification("Convert Error!", true); + console.error(error); + return null; + } + } + resetDelay() { CONFIG.visual.delay = Number(localStorage.getItem(`lyrics-delay:${Spicetify.Player.data.item.uri}`)) || 0; } @@ -1011,6 +1065,7 @@ 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, }; @@ -1039,6 +1094,7 @@ 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, }; @@ -1069,6 +1125,7 @@ class LyricsContainer extends react.Component { const hasMusixmatchLanguages = Array.isArray(this.state.musixmatchAvailableTranslations) && this.state.musixmatchAvailableTranslations.length > 0; const hasTranslation = this.state.neteaseTranslation !== null || this.state.musixmatchTranslation !== null || hasMusixmatchLanguages; const hasPerformer = !!this.state.currentLyrics?.some((line) => line.performer); + const romanizationLines = this.state.pinyin?.mode === mode ? this.state.pinyin.lines : null; if (mode !== -1) { showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED); @@ -1089,6 +1146,7 @@ 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) { activeItem = react.createElement(UnsyncedLyricsPage, { @@ -1097,6 +1155,7 @@ 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, { From 82eb323581557794780ff1757d565e35d61eb455 Mon Sep 17 00:00:00 2001 From: Hein Thant Maung Maung Date: Mon, 20 Jul 2026 14:28:58 +0700 Subject: [PATCH 2/2] feat(lyrics-plus): handle the case where romanizeLyrics returns null --- CustomApps/lyrics-plus/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js index 82c7ad71c0..9bfda8abb4 100644 --- a/CustomApps/lyrics-plus/index.js +++ b/CustomApps/lyrics-plus/index.js @@ -716,8 +716,8 @@ class LyricsContainer extends react.Component { const uri = lyricsState.uri; this.romanizeLyrics(lyrics).then((lines) => { this._romanizing = false; - if (lines && uri === this.state.uri) { - this.setState({ pinyin: { mode, lines } }); + if (uri === this.state.uri) { + this.setState({ pinyin: { mode, lines: lines || null } }); } }); }