diff --git a/CustomApps/lyrics-plus/OptionsMenu.js b/CustomApps/lyrics-plus/OptionsMenu.js index 35e3cafe48..eaa678651b 100644 --- a/CustomApps/lyrics-plus/OptionsMenu.js +++ b/CustomApps/lyrics-plus/OptionsMenu.js @@ -85,6 +85,76 @@ const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bol ); }); +const TranslationMenu = react.memo(({ showTranslationButton, translatorLoaded }) => { + if (!showTranslationButton) return null; + + return react.createElement( + Spicetify.ReactComponent.ContextMenu, + { + menu: react.createElement( + Spicetify.ReactComponent.Menu, + {}, + react.createElement("h3", null, " Conversions"), + translatorLoaded + ? react.createElement(OptionList, { + items: [ + { + desc: "Mode", + key: "translation-mode", + type: ConfigSelection, + options: { + furigana: "Furigana", + romaji: "Romaji", + hiragana: "Hiragana", + katakana: "Katakana" + }, + renderInline: true + }, + { + desc: "Convert", + key: "translate", + type: ConfigSlider, + trigger: "click", + action: "toggle", + renderInline: true + } + ], + onChange: (name, value) => { + CONFIG.visual[name] = value; + localStorage.setItem(`${APP_NAME}:visual:${name}`, value); + lyricContainerUpdate && lyricContainerUpdate(); + } + }) + : react.createElement( + "div", + null, + react.createElement("p1", null, "Loading"), + react.createElement("div", { class: "lyrics-translation-spinner" }, "") + ) + ), + trigger: "click", + action: "toggle", + renderInline: true + }, + react.createElement( + "button", + { + className: "lyrics-config-button" + }, + react.createElement( + "p1", + { + width: 16, + height: 16, + viewBox: "0 0 16 10.3", + fill: "currentColor" + }, + "あ" + ) + ) + ); +}); + const AdjustmentsMenu = react.memo(({ mode }) => { return react.createElement( Spicetify.ReactComponent.ContextMenu, diff --git a/CustomApps/lyrics-plus/README.md b/CustomApps/lyrics-plus/README.md index ffa47ce008..0a961866fb 100644 --- a/CustomApps/lyrics-plus/README.md +++ b/CustomApps/lyrics-plus/README.md @@ -3,10 +3,11 @@ ### Lyrics Plus Show current track lyrics. Current lyrics providers: -- Internal Spotify lyrics service. -- Netease: From Chinese developers and users. Provides karaoke and synced lyrics. -- Musixmatch: A company from Italy. Provided synced lyrics. -- Genius: Provide unsynced lyrics but with description/insight from artists themselve. + +- Internal Spotify lyrics service. +- Netease: From Chinese developers and users. Provides karaoke and synced lyrics. +- Musixmatch: A company from Italy. Provided synced lyrics. +- Genius: Provide unsynced lyrics but with description/insight from artists themselve. ![kara](./kara.png) @@ -22,6 +23,10 @@ Lyrics in Unsynced and Genius modes can be search and jump to. Hit Ctrl + Shift ![search](./search.png) +Choose between different option of displaying Japanese lyrics. (Furigana, Romaji, Hirgana, Katakana) + +![conversion](./conversion.png) + Customise colors, change providers' priorities in config menu. Config menu locates in Profile Menu (top right button with your user name). To install, run: @@ -33,5 +38,6 @@ spicetify apply ### Credits -- A few parts of app code are taken from Spotify official app, including SyncedLyricsPage, CSS animation and TabBar. Please do not distribute these code else where out of Spotify/Spicetify context. -- Netease synced lyrics parser is adapted from [mantou132/Spotify-Lyrics](https://github.com/mantou132/Spotify-Lyrics). Give it a Star if you like this app. +- A few parts of app code are taken from Spotify official app, including SyncedLyricsPage, CSS animation and TabBar. Please do not distribute these code else where out of Spotify/Spicetify context. +- Netease synced lyrics parser is adapted from [mantou132/Spotify-Lyrics](https://github.com/mantou132/Spotify-Lyrics). Give it a Star if you like this app. +- The algorithm for converting Japanese lyrics is based on [Hexenq's Kuroshiro](https://github.com/hexenq/kuroshiro). diff --git a/CustomApps/lyrics-plus/Translator.js b/CustomApps/lyrics-plus/Translator.js new file mode 100644 index 0000000000..36c36815d0 --- /dev/null +++ b/CustomApps/lyrics-plus/Translator.js @@ -0,0 +1,70 @@ +const kuroshiroPath = "https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js"; +const kuromojiPath = "https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js"; + +const dictPath = "https:/cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict"; + +class Translator { + constructor() { + this.includeExternal(kuroshiroPath); + this.includeExternal(kuromojiPath); + + this.createKuroshiro(); + + this.finished = false; + } + + includeExternal(url) { + var s = document.createElement("script"); + s.setAttribute("type", "text/javascript"); + s.setAttribute("src", url); + var nodes = document.getElementsByTagName("*"); + var node = nodes[nodes.length - 1].parentNode; + node.appendChild(s); + } + + /** + * Fix an issue with kuromoji when loading dict from external urls + * Adapted from: https://github.com/mobilusoss/textlint-browser-runner/pull/7 + */ + applyKuromojiFix() { + if (typeof XMLHttpRequest.prototype.realOpen !== "undefined") return; + XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, bool) { + if (url.indexOf(dictPath.replace("https://", "https:/")) === 0) { + this.realOpen(method, url.replace("https:/", "https://"), bool); + } else { + this.realOpen(method, url, bool); + } + }; + } + + async createKuroshiro() { + if (typeof Kuroshiro === "undefined" || typeof KuromojiAnalyzer === "undefined") { + //Waiting for JSDeliver to load Kuroshiro and Kuromoji + setTimeout(this.createKuroshiro.bind(this), 50); + return; + } + + this.kuroshiro = new Kuroshiro.default(); + + this.applyKuromojiFix(); + + this.kuroshiro.init(new KuromojiAnalyzer({ dictPath: dictPath })).then( + function () { + this.finished = true; + }.bind(this) + ); + } + + async romajifyText(text, target = "romaji", mode = "spaced") { + if (!this.finished) { + setTimeout(this.romajifyText.bind(this), 100, text, target, mode); + return; + } + + return this.kuroshiro.convert(text, { + to: target, + mode: mode + }); + } +} diff --git a/CustomApps/lyrics-plus/Utils.js b/CustomApps/lyrics-plus/Utils.js index de8775efbe..7666f927fe 100644 --- a/CustomApps/lyrics-plus/Utils.js +++ b/CustomApps/lyrics-plus/Utils.js @@ -54,4 +54,31 @@ class Utils { static capitalize(s) { return s.replace(/^(\w)/, $1 => $1.toUpperCase()); } + + static isJapanese(lyrics) { + for (let lyric of lyrics) + if (/[\u3000-\u303F]|[\u3040-\u309F]|[\u30A0-\u30FF]|[\uFF00-\uFFEF]|[\u4E00-\u9FAF]|[\u2605-\u2606]|[\u2190-\u2195]|\u203B/g.test(lyric.text)) + return true; + return false; + } + + static rubyTextToReact(s) { + const react = Spicetify.React; + + const rubyElems = s.split(""); + const reactChildren = []; + + reactChildren.push(rubyElems[0]); + + for (let i = 1; i < rubyElems.length; i++) { + const kanji = rubyElems[i].split("")[0]; + const furigana = rubyElems[i].split("")[1].split("")[0]; + + reactChildren.push(react.createElement("ruby", null, kanji, react.createElement("rt", null, furigana))); + + reactChildren.push(rubyElems[i].split("")[1]); + } + + return react.createElement("p1", null, reactChildren); + } } diff --git a/CustomApps/lyrics-plus/conversion.png b/CustomApps/lyrics-plus/conversion.png new file mode 100644 index 0000000000..669124681e Binary files /dev/null and b/CustomApps/lyrics-plus/conversion.png differ diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js index 96fbb2269a..058823f4ff 100644 --- a/CustomApps/lyrics-plus/index.js +++ b/CustomApps/lyrics-plus/index.js @@ -46,6 +46,8 @@ 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", + ["translation-mode"]: localStorage.getItem("lyrics-plus:visual:translation-mode") || "furigana", + ["translate"]: getConfig("lyrics-plus:visual:translate"), ["fade-blur"]: getConfig("lyrics-plus:visual:fade-blur"), ["fullscreen-key"]: localStorage.getItem("lyrics-plus:visual:fullscreen-key") || "f12", ["synced-compact"]: getConfig("lyrics-plus:visual:synced-compact"), @@ -118,6 +120,10 @@ class LyricsContainer extends react.Component { unsynced: null, genius: null, genius2: null, + romaji: null, + furigana: null, + hiragana: null, + katakana: null, uri: "", provider: "", colors: { @@ -142,6 +148,7 @@ class LyricsContainer extends react.Component { this.fullscreenContainer.id = "lyrics-fullscreen-container"; this.mousetrap = new Spicetify.Mousetrap(); this.containerRef = react.createRef(null); + this.translator = new Translator(); } infoFromTrack(track) { @@ -220,6 +227,7 @@ class LyricsContainer extends react.Component { } async fetchLyrics(track, mode = -1) { + this.state.furigana = this.state.romaji = this.state.hirgana = this.state.katakana = null; const info = this.infoFromTrack(track); if (!info) { this.setState({ error: "No track info" }); @@ -236,24 +244,63 @@ class LyricsContainer extends react.Component { if (CACHE[info.uri]?.[CONFIG.modes[mode]]) { this.resetDelay(); this.setState({ ...CACHE[info.uri] }); + this.translateLyrics(); return; } } else { if (CACHE[info.uri]) { this.resetDelay(); this.setState({ ...CACHE[info.uri] }); + this.translateLyrics(); return; } } this.setState({ ...emptyState, isLoading: true }); const resp = await this.tryServices(info, mode); + // In case user skips tracks too fast and multiple callbacks // set wrong lyrics to current track. if (resp.uri === this.currentTrackUri) { this.resetDelay(); this.setState({ ...resp, isLoading: false }); } + + this.translateLyrics(); + } + + async translateLyrics() { + if (!this.translator || !this.translator.finished) { + setTimeout(this.translateLyrics.bind(this), 100); + return; + } + + const lyricsToTranslate = this.state.synced ?? this.state.unsynced; + + if (!lyricsToTranslate || !Utils.isJapanese(lyricsToTranslate)) return; + + let lyricText = ""; + for (let lyric of lyricsToTranslate) lyricText += lyric.text + "\n"; + + [ + ["romaji", "spaced", "romaji"], + ["hiragana", "furigana", "furigana"], + ["hiragana", "normal", "hiragana"], + ["katakana", "normal", "katakana"] + ].map(params => + this.translator.romajifyText(lyricText, params[0], params[1]).then(result => { + const translatedLines = result.split("\n"); + + this.state[params[2]] = []; + + for (let i = 0; i < lyricsToTranslate.length; i++) + this.state[params[2]].push({ + startTime: lyricsToTranslate[i].startTime || 0, + text: Utils.rubyTextToReact(translatedLines[i]) + }); + lyricContainerUpdate && lyricContainerUpdate(); + }) + ); } resetDelay() { @@ -500,6 +547,7 @@ class LyricsContainer extends react.Component { } } + const translatedLyrics = this.state[CONFIG.visual["translation-mode"]]; let activeItem; if (mode !== -1) { @@ -514,14 +562,14 @@ class LyricsContainer extends react.Component { } else if (mode === SYNCED && this.state.synced) { activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, { trackUri: this.state.uri, - lyrics: this.state.synced, + lyrics: CONFIG.visual["translate"] && translatedLyrics ? translatedLyrics : this.state.synced, provider: this.state.provider, copyright: this.state.copyright }); } else if (mode === UNSYNCED && this.state.unsynced) { activeItem = react.createElement(UnsyncedLyricsPage, { trackUri: this.state.uri, - lyrics: this.state.unsynced, + lyrics: CONFIG.visual["translate"] && translatedLyrics ? translatedLyrics : this.state.unsynced, provider: this.state.provider, copyright: this.state.copyright }); @@ -559,6 +607,11 @@ class LyricsContainer extends react.Component { } this.state.mode = mode; + const showTranslationButton = + (this.state.synced || this.state.unsynced) && + Utils.isJapanese(this.state.synced || this.state.unsynced) && + (mode == SYNCED || mode == UNSYNCED); + const translatorLoaded = this.translator.finished; const out = react.createElement( "div", @@ -579,6 +632,10 @@ class LyricsContainer extends react.Component { { className: "lyrics-config-button-container" }, + react.createElement(TranslationMenu, { + showTranslationButton, + translatorLoaded + }), react.createElement(AdjustmentsMenu, { mode }), react.createElement( Spicetify.ReactComponent.TooltipWrapper, diff --git a/CustomApps/lyrics-plus/manifest.json b/CustomApps/lyrics-plus/manifest.json index b06091de3c..4e9d8f037f 100644 --- a/CustomApps/lyrics-plus/manifest.json +++ b/CustomApps/lyrics-plus/manifest.json @@ -1,81 +1,82 @@ { - "name": { - "ms": "Lyrics", - "gu": "Lyrics", - "ko": "Lyrics", - "pa-IN": "Lyrics", - "az": "Lyrics", - "ru": "Текст", - "uk": "Lyrics", - "nb": "Lyrics", - "sv": "Låttext", - "sw": "Lyrics", - "ur": "Lyrics", - "bho": "Lyrics", - "pa-PK": "Lyrics", - "te": "Lyrics", - "ro": "Lyrics", - "vi": "Lời bài hát", - "am": "Lyrics", - "bn": "Lyrics", - "en": "Lyrics", - "id": "Lirik", - "bg": "Lyrics", - "da": "Lyrics", - "es-419": "Letras", - "mr": "Lyrics", - "ml": "Lyrics", - "th": "เนื้อเพลง", - "tr": "Şarkı Sözleri", - "is": "Lyrics", - "fa": "Lyrics", - "or": "Lyrics", - "he": "Lyrics", - "hi": "Lyrics", - "zh-TW": "歌詞", - "sr": "Lyrics", - "pt-BR": "Letra", - "zu": "Lyrics", - "nl": "Songteksten", - "es": "Letra", - "lt": "Lyrics", - "ja": "歌詞", - "st": "Lyrics", - "it": "Lyrics", - "el": "Στίχοι", - "pt-PT": "Lyrics", - "kn": "Lyrics", - "de": "Songtext", - "fr": "Paroles", - "ne": "Lyrics", - "ar": "الكلمات", - "af": "Lyrics", - "et": "Lyrics", - "pl": "Tekst", - "ta": "Lyrics", - "sl": "Lyrics", - "pk": "Lyrics", - "hr": "Lyrics", - "sk": "Lyrics", - "fi": "Sanat", - "lv": "Lyrics", - "fil": "Lyrics", - "fr-CA": "Paroles", - "cs": "Text", - "zh-CN": "Lyrics", - "hu": "Dalszöveg" - }, - "icon": "", - "active-icon": "", - "subfiles": [ - "ProviderNetease.js", - "ProviderMusixmatch.js", - "ProviderGenius.js", - "Providers.js", - "Pages.js", - "OptionsMenu.js", - "TabBar.js", - "Utils.js", - "Settings.js" - ] + "name": { + "ms": "Lyrics", + "gu": "Lyrics", + "ko": "Lyrics", + "pa-IN": "Lyrics", + "az": "Lyrics", + "ru": "Текст", + "uk": "Lyrics", + "nb": "Lyrics", + "sv": "Låttext", + "sw": "Lyrics", + "ur": "Lyrics", + "bho": "Lyrics", + "pa-PK": "Lyrics", + "te": "Lyrics", + "ro": "Lyrics", + "vi": "Lời bài hát", + "am": "Lyrics", + "bn": "Lyrics", + "en": "Lyrics", + "id": "Lirik", + "bg": "Lyrics", + "da": "Lyrics", + "es-419": "Letras", + "mr": "Lyrics", + "ml": "Lyrics", + "th": "เนื้อเพลง", + "tr": "Şarkı Sözleri", + "is": "Lyrics", + "fa": "Lyrics", + "or": "Lyrics", + "he": "Lyrics", + "hi": "Lyrics", + "zh-TW": "歌詞", + "sr": "Lyrics", + "pt-BR": "Letra", + "zu": "Lyrics", + "nl": "Songteksten", + "es": "Letra", + "lt": "Lyrics", + "ja": "歌詞", + "st": "Lyrics", + "it": "Lyrics", + "el": "Στίχοι", + "pt-PT": "Lyrics", + "kn": "Lyrics", + "de": "Songtext", + "fr": "Paroles", + "ne": "Lyrics", + "ar": "الكلمات", + "af": "Lyrics", + "et": "Lyrics", + "pl": "Tekst", + "ta": "Lyrics", + "sl": "Lyrics", + "pk": "Lyrics", + "hr": "Lyrics", + "sk": "Lyrics", + "fi": "Sanat", + "lv": "Lyrics", + "fil": "Lyrics", + "fr-CA": "Paroles", + "cs": "Text", + "zh-CN": "Lyrics", + "hu": "Dalszöveg" + }, + "icon": "", + "active-icon": "", + "subfiles": [ + "ProviderNetease.js", + "ProviderMusixmatch.js", + "ProviderGenius.js", + "Providers.js", + "Pages.js", + "OptionsMenu.js", + "TabBar.js", + "Utils.js", + "Settings.js", + "Translator.js" + ] } diff --git a/CustomApps/lyrics-plus/style.css b/CustomApps/lyrics-plus/style.css index e2aa843f0c..22fdaee27c 100644 --- a/CustomApps/lyrics-plus/style.css +++ b/CustomApps/lyrics-plus/style.css @@ -589,10 +589,32 @@ button.switch.small { z-index: 2; } +.lyrics-translation-spinner { + border: 2px solid #cdcdcd; + border-top: 3px solid rgba(255, 255, 255, 0); + border-radius: 50%; + width: 15px; + height: 15px; + margin-left: 5px; + margin-top: 5px; + display: inline-block; + -webkit-animation: spin 1s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + .lyrics-config-button { align-items: center; background-color: rgba(0, 0, 0, 0.5); border: 0; + margin: 5px; border-radius: 4px; color: #eee; cursor: pointer;