From df8ff9731c594d13ba2e74b57f77e9c6424964c1 Mon Sep 17 00:00:00 2001 From: ianz56 Date: Sun, 2 Feb 2025 18:26:50 +0700 Subject: [PATCH] Added select language translation for Musixmatch provider --- CustomApps/lyrics-plus/OptionsMenu.js | 620 ++++---- CustomApps/lyrics-plus/ProviderMusixmatch.js | 396 ++--- CustomApps/lyrics-plus/Settings.js | 1400 ++++++++++-------- 3 files changed, 1275 insertions(+), 1141 deletions(-) diff --git a/CustomApps/lyrics-plus/OptionsMenu.js b/CustomApps/lyrics-plus/OptionsMenu.js index 30e986599d..7eb4187bde 100644 --- a/CustomApps/lyrics-plus/OptionsMenu.js +++ b/CustomApps/lyrics-plus/OptionsMenu.js @@ -1,326 +1,334 @@ const OptionsMenuItemIcon = react.createElement( - "svg", - { - width: 16, - height: 16, - viewBox: "0 0 16 16", - fill: "currentColor", - }, - react.createElement("path", { - d: "M13.985 2.383L5.127 12.754 1.388 8.375l-.658.77 4.397 5.149 9.618-11.262z", - }) + "svg", + { + width: 16, + height: 16, + viewBox: "0 0 16 16", + fill: "currentColor", + }, + react.createElement("path", { + d: "M13.985 2.383L5.127 12.754 1.388 8.375l-.658.77 4.397 5.149 9.618-11.262z", + }) ); const OptionsMenuItem = react.memo(({ onSelect, value, isSelected }) => { - return react.createElement( - Spicetify.ReactComponent.MenuItem, - { - onClick: onSelect, - icon: isSelected ? OptionsMenuItemIcon : null, - trailingIcon: isSelected ? OptionsMenuItemIcon : null, - }, - value - ); + return react.createElement( + Spicetify.ReactComponent.MenuItem, + { + onClick: onSelect, + icon: isSelected ? OptionsMenuItemIcon : null, + trailingIcon: isSelected ? OptionsMenuItemIcon : null, + }, + value + ); }); -const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bold = false }) => { - /** - * ) } - * > - * - * - */ - const menuRef = react.useRef(null); - return react.createElement( - Spicetify.ReactComponent.ContextMenu, - { - menu: react.createElement( - Spicetify.ReactComponent.Menu, - {}, - options.map(({ key, value }) => - react.createElement(OptionsMenuItem, { - value, - onSelect: () => { - onSelect(key); - // Close menu on item click - menuRef.current?.click(); - }, - isSelected: selected?.key === key, - }) - ) - ), - trigger: "click", - action: "toggle", - renderInline: false, - }, - react.createElement( - "button", - { - className: "optionsMenu-dropBox", - ref: menuRef, - }, - react.createElement( - "span", - { - className: bold ? "main-type-mestoBold" : "main-type-mesto", - }, - selected?.value || defaultValue - ), - react.createElement( - "svg", - { - height: "16", - width: "16", - fill: "currentColor", - viewBox: "0 0 16 16", - }, - react.createElement("path", { - d: "M3 6l5 5.794L13 6z", - }) - ) - ) - ); -}); +const OptionsMenu = react.memo( + ({ options, onSelect, selected, defaultValue, bold = false }) => { + /** + * ) } + * > + * + * + */ + const menuRef = react.useRef(null); + return react.createElement( + Spicetify.ReactComponent.ContextMenu, + { + menu: react.createElement( + Spicetify.ReactComponent.Menu, + {}, + options.map(({ key, value }) => + react.createElement(OptionsMenuItem, { + value, + onSelect: () => { + onSelect(key); + // Close menu on item click + menuRef.current?.click(); + }, + isSelected: selected?.key === key, + }) + ) + ), + trigger: "click", + action: "toggle", + renderInline: false, + }, + react.createElement( + "button", + { + className: "optionsMenu-dropBox", + ref: menuRef, + }, + react.createElement( + "span", + { + className: bold ? "main-type-mestoBold" : "main-type-mesto", + }, + selected?.value || defaultValue + ), + react.createElement( + "svg", + { + height: "16", + width: "16", + fill: "currentColor", + viewBox: "0 0 16 16", + }, + react.createElement("path", { + d: "M3 6l5 5.794L13 6z", + }) + ) + ) + ); + } +); const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => { - const items = useMemo(() => { - let sourceOptions = { - none: "None", - }; + const items = useMemo(() => { + let sourceOptions = {}; - const languageOptions = { - off: "Off", - "zh-hans": "Chinese (Simplified)", - "zh-hant": "Chinese (Traditional)", - ja: "Japanese", - ko: "Korean", - }; + const languageOptions = { + off: "Off", + "zh-hans": "Chinese (Simplified)", + "zh-hant": "Chinese (Traditional)", + ja: "Japanese", + ko: "Korean", + }; - let modeOptions = {}; + let modeOptions = {}; - if (hasTranslation.musixmatch) { - sourceOptions = { - ...sourceOptions, - musixmatchTranslation: "English (Musixmatch)", - }; - } + if (hasTranslation.musixmatch) { + const selectedLanguage = CONFIG.visual["musixmatch-translation-language"]; + const languageName = new Intl.DisplayNames([selectedLanguage], { + type: "language", + }).of(selectedLanguage); + sourceOptions = { + ...sourceOptions, + musixmatchTranslation: `${languageName} (Musixmatch)`, + }; + } - if (hasTranslation.netease) { - sourceOptions = { - ...sourceOptions, - neteaseTranslation: "Chinese (Netease)", - }; - } + if (hasTranslation.netease) { + sourceOptions = { + ...sourceOptions, + neteaseTranslation: "Chinese (Netease)", + }; + } - switch (friendlyLanguage) { - case "japanese": { - modeOptions = { - furigana: "Furigana", - romaji: "Romaji", - hiragana: "Hiragana", - katakana: "Katakana", - }; - break; - } - case "korean": { - modeOptions = { - hangul: "Hangul", - romaja: "Romaja", - }; - break; - } - case "chinese": { - modeOptions = { - cn: "Simplified Chinese", - hk: "Traditional Chinese (Hong Kong)", - tw: "Traditional Chinese (Taiwan)", - }; - break; - } - } + switch (friendlyLanguage) { + case "japanese": { + modeOptions = { + furigana: "Furigana", + romaji: "Romaji", + hiragana: "Hiragana", + katakana: "Katakana", + }; + break; + } + case "korean": { + modeOptions = { + hangul: "Hangul", + romaja: "Romaja", + }; + break; + } + case "chinese": { + modeOptions = { + cn: "Simplified Chinese", + hk: "Traditional Chinese (Hong Kong)", + tw: "Traditional Chinese (Taiwan)", + }; + break; + } + } - return [ - { - desc: "Translation Provider", - key: "translate:translated-lyrics-source", - type: ConfigSelection, - options: sourceOptions, - renderInline: true, - }, - { - desc: "Language Override", - key: "translate:detect-language-override", - type: ConfigSelection, - options: languageOptions, - renderInline: true, - }, - { - desc: "Display Mode", - key: `translation-mode:${friendlyLanguage}`, - type: ConfigSelection, - options: modeOptions, - renderInline: true, - }, - { - desc: "Convert", - key: "translate", - type: ConfigSlider, - trigger: "click", - action: "toggle", - renderInline: true, - }, - ]; - }, [friendlyLanguage]); + return [ + { + desc: "Translation Provider", + key: "translate:translated-lyrics-source", + type: ConfigSelection, + options: sourceOptions, + renderInline: true, + }, + { + desc: "Language Override", + key: "translate:detect-language-override", + type: ConfigSelection, + options: languageOptions, + renderInline: true, + }, + { + desc: "Display Mode", + key: `translation-mode:${friendlyLanguage}`, + type: ConfigSelection, + options: modeOptions, + renderInline: true, + }, + { + desc: "Convert", + key: "translate", + type: ConfigSlider, + trigger: "click", + action: "toggle", + renderInline: true, + }, + ]; + }, [friendlyLanguage]); - useEffect(() => { - // Currently opened Context Menu does not receive prop changes - // If we were to use keys the Context Menu would close on re-render - const event = new CustomEvent("lyrics-plus", { - detail: { - type: "translation-menu", - items, - }, - }); - document.dispatchEvent(event); - }, [friendlyLanguage]); + useEffect(() => { + // Currently opened Context Menu does not receive prop changes + // If we were to use keys the Context Menu would close on re-render + const event = new CustomEvent("lyrics-plus", { + detail: { + type: "translation-menu", + items, + }, + }); + document.dispatchEvent(event); + }, [friendlyLanguage]); - return react.createElement( - Spicetify.ReactComponent.TooltipWrapper, - { - label: "Conversion", - }, - react.createElement( - "div", - { - className: "lyrics-tooltip-wrapper", - }, - react.createElement( - Spicetify.ReactComponent.ContextMenu, - { - menu: react.createElement( - Spicetify.ReactComponent.Menu, - {}, - react.createElement("h3", null, " Conversions"), - react.createElement(OptionList, { - type: "translation-menu", - items, - onChange: (name, value) => { - CONFIG.visual[name] = value; - localStorage.setItem(`${APP_NAME}:visual:${name}`, value); - lyricContainerUpdate?.(); - }, - }) - ), - 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", - }, - "⇄" - ) - ) - ) - ) - ); + return react.createElement( + Spicetify.ReactComponent.TooltipWrapper, + { + label: "Conversion", + }, + react.createElement( + "div", + { + className: "lyrics-tooltip-wrapper", + }, + react.createElement( + Spicetify.ReactComponent.ContextMenu, + { + menu: react.createElement( + Spicetify.ReactComponent.Menu, + {}, + react.createElement("h3", null, " Conversions"), + react.createElement(OptionList, { + type: "translation-menu", + items, + onChange: (name, value) => { + CONFIG.visual[name] = value; + localStorage.setItem(`${APP_NAME}:visual:${name}`, value); + lyricContainerUpdate?.(); + }, + }) + ), + 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.TooltipWrapper, - { - label: "Adjustments", - }, - react.createElement( - "div", - { - className: "lyrics-tooltip-wrapper", - }, - react.createElement( - Spicetify.ReactComponent.ContextMenu, - { - menu: react.createElement( - Spicetify.ReactComponent.Menu, - {}, - react.createElement("h3", null, " Adjustments"), - react.createElement(OptionList, { - items: [ - { - desc: "Font size", - key: "font-size", - type: ConfigAdjust, - min: fontSizeLimit.min, - max: fontSizeLimit.max, - step: fontSizeLimit.step, - }, - { - desc: "Track delay", - key: "delay", - type: ConfigAdjust, - min: Number.NEGATIVE_INFINITY, - max: Number.POSITIVE_INFINITY, - step: 250, - when: () => mode === SYNCED || mode === KARAOKE, - }, - { - desc: "Compact", - key: "synced-compact", - type: ConfigSlider, - when: () => mode === SYNCED || mode === KARAOKE, - }, - { - desc: "Dual panel", - key: "dual-genius", - type: ConfigSlider, - when: () => mode === GENIUS, - }, - ], - onChange: (name, value) => { - CONFIG.visual[name] = value; - localStorage.setItem(`${APP_NAME}:visual:${name}`, value); - name === "delay" && localStorage.setItem(`lyrics-delay:${Spicetify.Player.data.item.uri}`, value); - lyricContainerUpdate?.(); - }, - }) - ), - trigger: "click", - action: "toggle", - renderInline: true, - }, - react.createElement( - "button", - { - className: "lyrics-config-button", - }, - react.createElement( - "svg", - { - width: 16, - height: 16, - viewBox: "0 0 16 10.3", - fill: "currentColor", - }, - react.createElement("path", { - d: "M 10.8125,0 C 9.7756347,0 8.8094481,0.30798341 8,0.836792 7.1905519,0.30798341 6.2243653,0 5.1875,0 2.3439941,0 0,2.3081055 0,5.15625 0,8.0001222 2.3393555,10.3125 5.1875,10.3125 6.2243653,10.3125 7.1905519,10.004517 8,9.4757081 8.8094481,10.004517 9.7756347,10.3125 10.8125,10.3125 13.656006,10.3125 16,8.0043944 16,5.15625 16,2.3123779 13.660644,0 10.8125,0 Z M 8,2.0146484 C 8.2629394,2.2503662 8.4963378,2.5183106 8.6936034,2.8125 H 7.3063966 C 7.5036622,2.5183106 7.7370606,2.2503662 8,2.0146484 Z M 6.619995,4.6875 C 6.6560059,4.3625487 6.7292481,4.0485841 6.8350831,3.75 h 2.3298338 c 0.1059572,0.2985841 0.1790772,0.6125487 0.21521,0.9375 z M 9.380005,5.625 C 9.3439941,5.9499512 9.2707519,6.2639159 9.1649169,6.5625 H 6.8350831 C 6.7291259,6.2639159 6.6560059,5.9499512 6.6198731,5.625 Z M 5.1875,9.375 c -2.3435059,0 -4.25,-1.8925781 -4.25,-4.21875 0,-2.3261719 1.9064941,-4.21875 4.25,-4.21875 0.7366944,0 1.4296875,0.1899414 2.0330809,0.5233154 C 6.2563478,2.3981934 5.65625,3.7083741 5.65625,5.15625 c 0,1.4478759 0.6000978,2.7580566 1.5643309,3.6954347 C 6.6171875,9.1850584 5.9241944,9.375 5.1875,9.375 Z M 8,8.2978516 C 7.7370606,8.0621337 7.5036622,7.7938231 7.3063966,7.4996337 H 8.6936034 C 8.4963378,7.7938231 8.2629394,8.0621338 8,8.2978516 Z M 10.8125,9.375 C 10.075806,9.375 9.3828125,9.1850584 8.7794191,8.8516847 9.7436522,7.9143066 10.34375,6.6041259 10.34375,5.15625 10.34375,3.7083741 9.7436522,2.3981934 8.7794191,1.4608154 9.3828125,1.1274414 10.075806,0.9375 10.8125,0.9375 c 2.343506,0 4.25,1.8925781 4.25,4.21875 0,2.3261719 -1.906494,4.21875 -4.25,4.21875 z m 0,0", - }) - ) - ) - ) - ) - ); + return react.createElement( + Spicetify.ReactComponent.TooltipWrapper, + { + label: "Adjustments", + }, + react.createElement( + "div", + { + className: "lyrics-tooltip-wrapper", + }, + react.createElement( + Spicetify.ReactComponent.ContextMenu, + { + menu: react.createElement( + Spicetify.ReactComponent.Menu, + {}, + react.createElement("h3", null, " Adjustments"), + react.createElement(OptionList, { + items: [ + { + desc: "Font size", + key: "font-size", + type: ConfigAdjust, + min: fontSizeLimit.min, + max: fontSizeLimit.max, + step: fontSizeLimit.step, + }, + { + desc: "Track delay", + key: "delay", + type: ConfigAdjust, + min: Number.NEGATIVE_INFINITY, + max: Number.POSITIVE_INFINITY, + step: 250, + when: () => mode === SYNCED || mode === KARAOKE, + }, + { + desc: "Compact", + key: "synced-compact", + type: ConfigSlider, + when: () => mode === SYNCED || mode === KARAOKE, + }, + { + desc: "Dual panel", + key: "dual-genius", + type: ConfigSlider, + when: () => mode === GENIUS, + }, + ], + onChange: (name, value) => { + CONFIG.visual[name] = value; + localStorage.setItem(`${APP_NAME}:visual:${name}`, value); + name === "delay" && + localStorage.setItem( + `lyrics-delay:${Spicetify.Player.data.item.uri}`, + value + ); + lyricContainerUpdate?.(); + }, + }) + ), + trigger: "click", + action: "toggle", + renderInline: true, + }, + react.createElement( + "button", + { + className: "lyrics-config-button", + }, + react.createElement( + "svg", + { + width: 16, + height: 16, + viewBox: "0 0 16 10.3", + fill: "currentColor", + }, + react.createElement("path", { + d: "M 10.8125,0 C 9.7756347,0 8.8094481,0.30798341 8,0.836792 7.1905519,0.30798341 6.2243653,0 5.1875,0 2.3439941,0 0,2.3081055 0,5.15625 0,8.0001222 2.3393555,10.3125 5.1875,10.3125 6.2243653,10.3125 7.1905519,10.004517 8,9.4757081 8.8094481,10.004517 9.7756347,10.3125 10.8125,10.3125 13.656006,10.3125 16,8.0043944 16,5.15625 16,2.3123779 13.660644,0 10.8125,0 Z M 8,2.0146484 C 8.2629394,2.2503662 8.4963378,2.5183106 8.6936034,2.8125 H 7.3063966 C 7.5036622,2.5183106 7.7370606,2.2503662 8,2.0146484 Z M 6.619995,4.6875 C 6.6560059,4.3625487 6.7292481,4.0485841 6.8350831,3.75 h 2.3298338 c 0.1059572,0.2985841 0.1790772,0.6125487 0.21521,0.9375 z M 9.380005,5.625 C 9.3439941,5.9499512 9.2707519,6.2639159 9.1649169,6.5625 H 6.8350831 C 6.7291259,6.2639159 6.6560059,5.9499512 6.6198731,5.625 Z M 5.1875,9.375 c -2.3435059,0 -4.25,-1.8925781 -4.25,-4.21875 0,-2.3261719 1.9064941,-4.21875 4.25,-4.21875 0.7366944,0 1.4296875,0.1899414 2.0330809,0.5233154 C 6.2563478,2.3981934 5.65625,3.7083741 5.65625,5.15625 c 0,1.4478759 0.6000978,2.7580566 1.5643309,3.6954347 C 6.6171875,9.1850584 5.9241944,9.375 5.1875,9.375 Z M 8,8.2978516 C 7.7370606,8.0621337 7.5036622,7.7938231 7.3063966,7.4996337 H 8.6936034 C 8.4963378,7.7938231 8.2629394,8.0621338 8,8.2978516 Z M 10.8125,9.375 C 10.075806,9.375 9.3828125,9.1850584 8.7794191,8.8516847 9.7436522,7.9143066 10.34375,6.6041259 10.34375,5.15625 10.34375,3.7083741 9.7436522,2.3981934 8.7794191,1.4608154 9.3828125,1.1274414 10.075806,0.9375 10.8125,0.9375 c 2.343506,0 4.25,1.8925781 4.25,4.21875 0,2.3261719 -1.906494,4.21875 -4.25,4.21875 z m 0,0", + }) + ) + ) + ) + ) + ); }); diff --git a/CustomApps/lyrics-plus/ProviderMusixmatch.js b/CustomApps/lyrics-plus/ProviderMusixmatch.js index bee377b9a5..f1ccec7a0b 100644 --- a/CustomApps/lyrics-plus/ProviderMusixmatch.js +++ b/CustomApps/lyrics-plus/ProviderMusixmatch.js @@ -1,191 +1,209 @@ const ProviderMusixmatch = (() => { - const headers = { - authority: "apic-desktop.musixmatch.com", - cookie: "x-mxm-token-guid=", - }; - - async function findLyrics(info) { - const baseURL = - "https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get?format=json&namespace=lyrics_richsynched&subtitle_format=mxm&app_id=web-desktop-app-v1.0&"; - - const durr = info.duration / 1000; - - const params = { - q_album: info.album, - q_artist: info.artist, - q_artists: info.artist, - q_track: info.title, - track_spotify_id: info.uri, - q_duration: durr, - f_subtitle_length: Math.floor(durr), - usertoken: CONFIG.providers.musixmatch.token, - }; - - const finalURL = - baseURL + - Object.keys(params) - .map((key) => `${key}=${encodeURIComponent(params[key])}`) - .join("&"); - - let body = await Spicetify.CosmosAsync.get(finalURL, null, headers); - - body = body.message.body.macro_calls; - - if (body["matcher.track.get"].message.header.status_code !== 200) { - return { - error: `Requested error: ${body["matcher.track.get"].message.header.mode}`, - uri: info.uri, - }; - } - if (body["track.lyrics.get"]?.message?.body?.lyrics?.restricted) { - return { - error: "Unfortunately we're not authorized to show these lyrics.", - uri: info.uri, - }; - } - - return body; - } - - async function getKaraoke(body) { - const meta = body?.["matcher.track.get"]?.message?.body; - if (!meta) { - return null; - } - - if (!meta.track.has_richsync || meta.track.instrumental) { - return null; - } - - const baseURL = "https://apic-desktop.musixmatch.com/ws/1.1/track.richsync.get?format=json&subtitle_format=mxm&app_id=web-desktop-app-v1.0&"; - - const params = { - f_subtitle_length: meta.track.track_length, - q_duration: meta.track.track_length, - commontrack_id: meta.track.commontrack_id, - usertoken: CONFIG.providers.musixmatch.token, - }; - - const finalURL = - baseURL + - Object.keys(params) - .map((key) => `${key}=${encodeURIComponent(params[key])}`) - .join("&"); - - let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); - - if (result.message.header.status_code !== 200) { - return null; - } - - result = result.message.body; - - const parsedKaraoke = JSON.parse(result.richsync.richsync_body).map((line) => { - const startTime = line.ts * 1000; - const endTime = line.te * 1000; - const words = line.l; - - const text = words.map((word, index, words) => { - const wordText = word.c; - const wordStartTime = word.o * 1000; - const nextWordStartTime = words[index + 1]?.o * 1000; - - const time = !Number.isNaN(nextWordStartTime) ? nextWordStartTime - wordStartTime : endTime - (wordStartTime + startTime); - - return { - word: wordText, - time, - }; - }); - return { - startTime, - text, - }; - }); - - return parsedKaraoke; - } - - function getSynced(body) { - const meta = body?.["matcher.track.get"]?.message?.body; - if (!meta) { - return null; - } - - const hasSynced = meta?.track?.has_subtitles; - - const isInstrumental = meta?.track?.instrumental; - - if (isInstrumental) { - return [{ text: "♪ Instrumental ♪", startTime: "0000" }]; - } - if (hasSynced) { - const subtitle = body["track.subtitles.get"]?.message?.body?.subtitle_list?.[0]?.subtitle; - if (!subtitle) { - return null; - } - - return JSON.parse(subtitle.subtitle_body).map((line) => ({ - text: line.text || "♪", - startTime: line.time.total * 1000, - })); - } - - return null; - } - - function getUnsynced(body) { - const meta = body?.["matcher.track.get"]?.message?.body; - if (!meta) { - return null; - } - - const hasUnSynced = meta.track.has_lyrics || meta.track.has_lyrics_crowd; - - const isInstrumental = meta?.track?.instrumental; - - if (isInstrumental) { - return [{ text: "♪ Instrumental ♪" }]; - } - if (hasUnSynced) { - const lyrics = body["track.lyrics.get"]?.message?.body?.lyrics?.lyrics_body; - if (!lyrics) { - return null; - } - return lyrics.split("\n").map((text) => ({ text })); - } - - return null; - } - - async function getTranslation(body) { - const track_id = body?.["matcher.track.get"]?.message?.body?.track?.track_id; - if (!track_id) return null; - - const baseURL = - "https://apic-desktop.musixmatch.com/ws/1.1/crowd.track.translations.get?translation_fields_set=minimal&selected_language=en&comment_format=text&format=json&app_id=web-desktop-app-v1.0&"; - - const params = { - track_id, - usertoken: CONFIG.providers.musixmatch.token, - }; - - const finalURL = - baseURL + - Object.keys(params) - .map((key) => `${key}=${encodeURIComponent(params[key])}`) - .join("&"); - - let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); - - if (result.message.header.status_code !== 200) return null; - - result = result.message.body; - - if (!result.translations_list?.length) return null; - - return result.translations_list.map(({ translation }) => ({ translation: translation.description, matchedLine: translation.matched_line })); - } - - return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation }; + const headers = { + authority: "apic-desktop.musixmatch.com", + cookie: "x-mxm-token-guid=", + }; + + async function findLyrics(info) { + const baseURL = + "https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get?format=json&namespace=lyrics_richsynched&subtitle_format=mxm&app_id=web-desktop-app-v1.0&"; + + const durr = info.duration / 1000; + + const params = { + q_album: info.album, + q_artist: info.artist, + q_artists: info.artist, + q_track: info.title, + track_spotify_id: info.uri, + q_duration: durr, + f_subtitle_length: Math.floor(durr), + usertoken: CONFIG.providers.musixmatch.token, + }; + + const finalURL = + baseURL + + Object.keys(params) + .map((key) => `${key}=${encodeURIComponent(params[key])}`) + .join("&"); + + let body = await Spicetify.CosmosAsync.get(finalURL, null, headers); + + body = body.message.body.macro_calls; + + if (body["matcher.track.get"].message.header.status_code !== 200) { + return { + error: `Requested error: ${body["matcher.track.get"].message.header.mode}`, + uri: info.uri, + }; + } + if (body["track.lyrics.get"]?.message?.body?.lyrics?.restricted) { + return { + error: "Unfortunately we're not authorized to show these lyrics.", + uri: info.uri, + }; + } + + return body; + } + + async function getKaraoke(body) { + const meta = body?.["matcher.track.get"]?.message?.body; + if (!meta) { + return null; + } + + if (!meta.track.has_richsync || meta.track.instrumental) { + return null; + } + + const baseURL = + "https://apic-desktop.musixmatch.com/ws/1.1/track.richsync.get?format=json&subtitle_format=mxm&app_id=web-desktop-app-v1.0&"; + + const params = { + f_subtitle_length: meta.track.track_length, + q_duration: meta.track.track_length, + commontrack_id: meta.track.commontrack_id, + usertoken: CONFIG.providers.musixmatch.token, + }; + + const finalURL = + baseURL + + Object.keys(params) + .map((key) => `${key}=${encodeURIComponent(params[key])}`) + .join("&"); + + let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); + + if (result.message.header.status_code !== 200) { + return null; + } + + result = result.message.body; + + const parsedKaraoke = JSON.parse(result.richsync.richsync_body).map( + (line) => { + const startTime = line.ts * 1000; + const endTime = line.te * 1000; + const words = line.l; + + const text = words.map((word, index, words) => { + const wordText = word.c; + const wordStartTime = word.o * 1000; + const nextWordStartTime = words[index + 1]?.o * 1000; + + const time = !Number.isNaN(nextWordStartTime) + ? nextWordStartTime - wordStartTime + : endTime - (wordStartTime + startTime); + + return { + word: wordText, + time, + }; + }); + return { + startTime, + text, + }; + } + ); + + return parsedKaraoke; + } + + function getSynced(body) { + const meta = body?.["matcher.track.get"]?.message?.body; + if (!meta) { + return null; + } + + const hasSynced = meta?.track?.has_subtitles; + + const isInstrumental = meta?.track?.instrumental; + + if (isInstrumental) { + return [{ text: "♪ Instrumental ♪", startTime: "0000" }]; + } + if (hasSynced) { + const subtitle = + body["track.subtitles.get"]?.message?.body?.subtitle_list?.[0] + ?.subtitle; + if (!subtitle) { + return null; + } + + return JSON.parse(subtitle.subtitle_body).map((line) => ({ + text: line.text || "♪", + startTime: line.time.total * 1000, + })); + } + + return null; + } + + function getUnsynced(body) { + const meta = body?.["matcher.track.get"]?.message?.body; + if (!meta) { + return null; + } + + const hasUnSynced = meta.track.has_lyrics || meta.track.has_lyrics_crowd; + + const isInstrumental = meta?.track?.instrumental; + + if (isInstrumental) { + return [{ text: "♪ Instrumental ♪" }]; + } + if (hasUnSynced) { + const lyrics = + body["track.lyrics.get"]?.message?.body?.lyrics?.lyrics_body; + if (!lyrics) { + return null; + } + return lyrics.split("\n").map((text) => ({ text })); + } + + return null; + } + + async function getTranslation(body) { + const track_id = + body?.["matcher.track.get"]?.message?.body?.track?.track_id; + if (!track_id) return null; + + const selectedLanguage = + CONFIG.visual["musixmatch-translation-language"] || "none"; + + if (selectedLanguage === "none") return null; + + const baseURL = + "https://apic-desktop.musixmatch.com/ws/1.1/crowd.track.translations.get?translation_fields_set=minimal&comment_format=text&format=json&app_id=web-desktop-app-v1.0&"; + + const params = { + track_id, + selected_language: selectedLanguage, + usertoken: CONFIG.providers.musixmatch.token, + }; + + const finalURL = + baseURL + + Object.keys(params) + .map((key) => `${key}=${encodeURIComponent(params[key])}`) + .join("&"); + + let result = await Spicetify.CosmosAsync.get(finalURL, null, headers); + + if (result.message.header.status_code !== 200) return null; + + result = result.message.body; + + if (!result.translations_list?.length) return null; + + return result.translations_list.map(({ translation }) => ({ + translation: translation.description, + matchedLine: translation.matched_line, + })); + } + + return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation }; })(); diff --git a/CustomApps/lyrics-plus/Settings.js b/CustomApps/lyrics-plus/Settings.js index 80109ec889..aab191ad7b 100644 --- a/CustomApps/lyrics-plus/Settings.js +++ b/CustomApps/lyrics-plus/Settings.js @@ -1,686 +1,794 @@ const ButtonSVG = ({ icon, active = true, onClick }) => { - return react.createElement( - "button", - { - className: `switch${active ? "" : " disabled"}`, - onClick, - }, - react.createElement("svg", { - width: 16, - height: 16, - viewBox: "0 0 16 16", - fill: "currentColor", - dangerouslySetInnerHTML: { - __html: icon, - }, - }) - ); + return react.createElement( + "button", + { + className: `switch${active ? "" : " disabled"}`, + onClick, + }, + react.createElement("svg", { + width: 16, + height: 16, + viewBox: "0 0 16 16", + fill: "currentColor", + dangerouslySetInnerHTML: { + __html: icon, + }, + }) + ); }; const SwapButton = ({ icon, disabled, onClick }) => { - return react.createElement( - "button", - { - className: "switch small", - onClick, - disabled, - }, - react.createElement("svg", { - width: 10, - height: 10, - viewBox: "0 0 16 16", - fill: "currentColor", - dangerouslySetInnerHTML: { - __html: icon, - }, - }) - ); + return react.createElement( + "button", + { + className: "switch small", + onClick, + disabled, + }, + react.createElement("svg", { + width: 10, + height: 10, + viewBox: "0 0 16 16", + fill: "currentColor", + dangerouslySetInnerHTML: { + __html: icon, + }, + }) + ); }; const CacheButton = () => { - let lyrics = {}; - - try { - const localLyrics = JSON.parse(localStorage.getItem("lyrics-plus:local-lyrics")); - if (!localLyrics || typeof localLyrics !== "object") { - throw ""; - } - lyrics = localLyrics; - } catch { - lyrics = {}; - } - - const [count, setCount] = useState(Object.keys(lyrics).length); - const text = count ? "Clear cached lyrics" : "No cached lyrics"; - - return react.createElement( - "button", - { - className: "btn", - onClick: () => { - localStorage.removeItem("lyrics-plus:local-lyrics"); - setCount(0); - }, - disabled: !count, - }, - text - ); + let lyrics = {}; + + try { + const localLyrics = JSON.parse( + localStorage.getItem("lyrics-plus:local-lyrics") + ); + if (!localLyrics || typeof localLyrics !== "object") { + throw ""; + } + lyrics = localLyrics; + } catch { + lyrics = {}; + } + + const [count, setCount] = useState(Object.keys(lyrics).length); + const text = count ? "Clear cached lyrics" : "No cached lyrics"; + + return react.createElement( + "button", + { + className: "btn", + onClick: () => { + localStorage.removeItem("lyrics-plus:local-lyrics"); + setCount(0); + }, + disabled: !count, + }, + text + ); }; const RefreshTokenButton = ({ setTokenCallback }) => { - const [buttonText, setButtonText] = useState("Refresh token"); - - useEffect(() => { - if (buttonText === "Refreshing token...") { - Spicetify.CosmosAsync.get("https://apic-desktop.musixmatch.com/ws/1.1/token.get?app_id=web-desktop-app-v1.0", null, { - authority: "apic-desktop.musixmatch.com", - }) - .then(({ message: response }) => { - if (response.header.status_code === 200 && response.body.user_token) { - setTokenCallback(response.body.user_token); - setButtonText("Token refreshed"); - } else if (response.header.status_code === 401) { - setButtonText("Too many attempts"); - } else { - setButtonText("Failed to refresh token"); - console.error("Failed to refresh token", response); - } - }) - .catch((error) => { - setButtonText("Failed to refresh token"); - console.error("Failed to refresh token", error); - }); - } - }, [buttonText]); - - return react.createElement( - "button", - { - className: "btn", - onClick: () => { - setButtonText("Refreshing token..."); - }, - disabled: buttonText !== "Refresh token", - }, - buttonText - ); + const [buttonText, setButtonText] = useState("Refresh token"); + + useEffect(() => { + if (buttonText === "Refreshing token...") { + Spicetify.CosmosAsync.get( + "https://apic-desktop.musixmatch.com/ws/1.1/token.get?app_id=web-desktop-app-v1.0", + null, + { + authority: "apic-desktop.musixmatch.com", + } + ) + .then(({ message: response }) => { + if (response.header.status_code === 200 && response.body.user_token) { + setTokenCallback(response.body.user_token); + setButtonText("Token refreshed"); + } else if (response.header.status_code === 401) { + setButtonText("Too many attempts"); + } else { + setButtonText("Failed to refresh token"); + console.error("Failed to refresh token", response); + } + }) + .catch((error) => { + setButtonText("Failed to refresh token"); + console.error("Failed to refresh token", error); + }); + } + }, [buttonText]); + + return react.createElement( + "button", + { + className: "btn", + onClick: () => { + setButtonText("Refreshing token..."); + }, + disabled: buttonText !== "Refresh token", + }, + buttonText + ); }; const ConfigSlider = ({ name, defaultValue, onChange = () => {} }) => { - const [active, setActive] = useState(defaultValue); - - const toggleState = useCallback(() => { - const state = !active; - setActive(state); - onChange(state); - }, [active]); - - return react.createElement( - "div", - { - className: "setting-row", - }, - react.createElement( - "label", - { - className: "col description", - }, - name - ), - react.createElement( - "div", - { - className: "col action", - }, - react.createElement(ButtonSVG, { - icon: Spicetify.SVGIcons.check, - active, - onClick: toggleState, - }) - ) - ); + const [active, setActive] = useState(defaultValue); + + const toggleState = useCallback(() => { + const state = !active; + setActive(state); + onChange(state); + }, [active]); + + return react.createElement( + "div", + { + className: "setting-row", + }, + react.createElement( + "label", + { + className: "col description", + }, + name + ), + react.createElement( + "div", + { + className: "col action", + }, + react.createElement(ButtonSVG, { + icon: Spicetify.SVGIcons.check, + active, + onClick: toggleState, + }) + ) + ); }; -const ConfigSelection = ({ name, defaultValue, options, onChange = () => {} }) => { - const [value, setValue] = useState(defaultValue); - - const setValueCallback = useCallback( - (event) => { - let value = event.target.value; - if (!Number.isNaN(Number(value))) { - value = Number.parseInt(value); - } - setValue(value); - onChange(value); - }, - [value, options] - ); - - useEffect(() => { - setValue(defaultValue); - }, [defaultValue]); - - if (!Object.keys(options).length) return null; - - return react.createElement( - "div", - { - className: "setting-row", - }, - react.createElement( - "label", - { - className: "col description", - }, - name - ), - react.createElement( - "div", - { - className: "col action", - }, - react.createElement( - "select", - { - className: "main-dropDown-dropDown", - value, - onChange: setValueCallback, - }, - Object.keys(options).map((item) => - react.createElement( - "option", - { - value: item, - }, - options[item] - ) - ) - ) - ) - ); +const ConfigSelection = ({ + name, + defaultValue, + options, + onChange = () => {}, +}) => { + const [value, setValue] = useState(defaultValue); + + const setValueCallback = useCallback( + (event) => { + let value = event.target.value; + if (!Number.isNaN(Number(value))) { + value = Number.parseInt(value); + } + setValue(value); + onChange(value); + }, + [value, options] + ); + + useEffect(() => { + setValue(defaultValue); + }, [defaultValue]); + + if (!Object.keys(options).length) return null; + + return react.createElement( + "div", + { + className: "setting-row", + }, + react.createElement( + "label", + { + className: "col description", + }, + name + ), + react.createElement( + "div", + { + className: "col action", + }, + react.createElement( + "select", + { + className: "main-dropDown-dropDown", + value, + onChange: setValueCallback, + }, + Object.keys(options).map((item) => + react.createElement( + "option", + { + value: item, + }, + options[item] + ) + ) + ) + ) + ); }; const ConfigInput = ({ name, defaultValue, onChange = () => {} }) => { - const [value, setValue] = useState(defaultValue); - - const setValueCallback = useCallback( - (event) => { - const value = event.target.value; - setValue(value); - onChange(value); - }, - [value] - ); - - return react.createElement( - "div", - { - className: "setting-row", - }, - react.createElement( - "label", - { - className: "col description", - }, - name - ), - react.createElement( - "div", - { - className: "col action", - }, - react.createElement("input", { - value, - onChange: setValueCallback, - }) - ) - ); + const [value, setValue] = useState(defaultValue); + + const setValueCallback = useCallback( + (event) => { + const value = event.target.value; + setValue(value); + onChange(value); + }, + [value] + ); + + return react.createElement( + "div", + { + className: "setting-row", + }, + react.createElement( + "label", + { + className: "col description", + }, + name + ), + react.createElement( + "div", + { + className: "col action", + }, + react.createElement("input", { + value, + onChange: setValueCallback, + }) + ) + ); }; -const ConfigAdjust = ({ name, defaultValue, step, min, max, onChange = () => {} }) => { - const [value, setValue] = useState(defaultValue); - - function adjust(dir) { - let temp = value + dir * step; - if (temp < min) { - temp = min; - } else if (temp > max) { - temp = max; - } - setValue(temp); - onChange(temp); - } - return react.createElement( - "div", - { - className: "setting-row", - }, - react.createElement( - "label", - { - className: "col description", - }, - name - ), - react.createElement( - "div", - { - className: "col action", - }, - react.createElement(SwapButton, { - icon: ``, - onClick: () => adjust(-1), - disabled: value === min, - }), - react.createElement( - "p", - { - className: "adjust-value", - }, - value - ), - react.createElement(SwapButton, { - icon: Spicetify.SVGIcons.plus2px, - onClick: () => adjust(1), - disabled: value === max, - }) - ) - ); +const ConfigAdjust = ({ + name, + defaultValue, + step, + min, + max, + onChange = () => {}, +}) => { + const [value, setValue] = useState(defaultValue); + + function adjust(dir) { + let temp = value + dir * step; + if (temp < min) { + temp = min; + } else if (temp > max) { + temp = max; + } + setValue(temp); + onChange(temp); + } + return react.createElement( + "div", + { + className: "setting-row", + }, + react.createElement( + "label", + { + className: "col description", + }, + name + ), + react.createElement( + "div", + { + className: "col action", + }, + react.createElement(SwapButton, { + icon: ``, + onClick: () => adjust(-1), + disabled: value === min, + }), + react.createElement( + "p", + { + className: "adjust-value", + }, + value + ), + react.createElement(SwapButton, { + icon: Spicetify.SVGIcons.plus2px, + onClick: () => adjust(1), + disabled: value === max, + }) + ) + ); }; const ConfigHotkey = ({ name, defaultValue, onChange = () => {} }) => { - const [value, setValue] = useState(defaultValue); - const [trap] = useState(new Spicetify.Mousetrap()); - - function record() { - trap.handleKey = (character, modifiers, e) => { - if (e.type === "keydown") { - const sequence = [...new Set([...modifiers, character])]; - if (sequence.length === 1 && sequence[0] === "esc") { - onChange(""); - setValue(""); - return; - } - setValue(sequence.join("+")); - } - }; - } - - function finishRecord() { - trap.handleKey = () => {}; - onChange(value); - } - - return react.createElement( - "div", - { - className: "setting-row", - }, - react.createElement( - "label", - { - className: "col description", - }, - name - ), - react.createElement( - "div", - { - className: "col action", - }, - react.createElement("input", { - value, - onFocus: record, - onBlur: finishRecord, - }) - ) - ); + const [value, setValue] = useState(defaultValue); + const [trap] = useState(new Spicetify.Mousetrap()); + + function record() { + trap.handleKey = (character, modifiers, e) => { + if (e.type === "keydown") { + const sequence = [...new Set([...modifiers, character])]; + if (sequence.length === 1 && sequence[0] === "esc") { + onChange(""); + setValue(""); + return; + } + setValue(sequence.join("+")); + } + }; + } + + function finishRecord() { + trap.handleKey = () => {}; + onChange(value); + } + + return react.createElement( + "div", + { + className: "setting-row", + }, + react.createElement( + "label", + { + className: "col description", + }, + name + ), + react.createElement( + "div", + { + className: "col action", + }, + react.createElement("input", { + value, + onFocus: record, + onBlur: finishRecord, + }) + ) + ); }; const ServiceAction = ({ item, setTokenCallback }) => { - switch (item.name) { - case "local": - return react.createElement(CacheButton); - case "musixmatch": - return react.createElement(RefreshTokenButton, { setTokenCallback }); - default: - return null; - } + switch (item.name) { + case "local": + return react.createElement(CacheButton); + case "musixmatch": + return react.createElement(RefreshTokenButton, { setTokenCallback }); + default: + return null; + } }; -const ServiceOption = ({ item, onToggle, onSwap, isFirst = false, isLast = false, onTokenChange = null }) => { - const [token, setToken] = useState(item.token); - const [active, setActive] = useState(item.on); - - const setTokenCallback = useCallback( - (token) => { - setToken(token); - onTokenChange(item.name, token); - }, - [item.token] - ); - - const toggleActive = useCallback(() => { - if (item.name === "genius" && spotifyVersion >= "1.2.31") return; - const state = !active; - setActive(state); - onToggle(item.name, state); - }, [active]); - - return react.createElement( - "div", - null, - react.createElement( - "div", - { - className: "setting-row", - }, - react.createElement( - "h3", - { - className: "col description", - }, - item.name - ), - react.createElement( - "div", - { - className: "col action", - }, - react.createElement(ServiceAction, { - item, - setTokenCallback, - }), - react.createElement(SwapButton, { - icon: Spicetify.SVGIcons["chart-up"], - onClick: () => onSwap(item.name, -1), - disabled: isFirst, - }), - react.createElement(SwapButton, { - icon: Spicetify.SVGIcons["chart-down"], - onClick: () => onSwap(item.name, 1), - disabled: isLast, - }), - react.createElement(ButtonSVG, { - icon: Spicetify.SVGIcons.check, - active, - onClick: toggleActive, - }) - ) - ), - react.createElement("span", { - dangerouslySetInnerHTML: { - __html: item.desc, - }, - }), - item.token !== undefined && - react.createElement("input", { - placeholder: `Place your ${item.name} token here`, - value: token, - onChange: (event) => setTokenCallback(event.target.value), - }) - ); +const ServiceOption = ({ + item, + onToggle, + onSwap, + isFirst = false, + isLast = false, + onTokenChange = null, +}) => { + const [token, setToken] = useState(item.token); + const [active, setActive] = useState(item.on); + + const setTokenCallback = useCallback( + (token) => { + setToken(token); + onTokenChange(item.name, token); + }, + [item.token] + ); + + const toggleActive = useCallback(() => { + if (item.name === "genius" && spotifyVersion >= "1.2.31") return; + const state = !active; + setActive(state); + onToggle(item.name, state); + }, [active]); + + return react.createElement( + "div", + null, + react.createElement( + "div", + { + className: "setting-row", + }, + react.createElement( + "h3", + { + className: "col description", + }, + item.name + ), + react.createElement( + "div", + { + className: "col action", + }, + react.createElement(ServiceAction, { + item, + setTokenCallback, + }), + react.createElement(SwapButton, { + icon: Spicetify.SVGIcons["chart-up"], + onClick: () => onSwap(item.name, -1), + disabled: isFirst, + }), + react.createElement(SwapButton, { + icon: Spicetify.SVGIcons["chart-down"], + onClick: () => onSwap(item.name, 1), + disabled: isLast, + }), + react.createElement(ButtonSVG, { + icon: Spicetify.SVGIcons.check, + active, + onClick: toggleActive, + }) + ) + ), + react.createElement("span", { + dangerouslySetInnerHTML: { + __html: item.desc, + }, + }), + item.token !== undefined && + react.createElement("input", { + placeholder: `Place your ${item.name} token here`, + value: token, + onChange: (event) => setTokenCallback(event.target.value), + }) + ); }; -const ServiceList = ({ itemsList, onListChange = () => {}, onToggle = () => {}, onTokenChange = () => {} }) => { - const [items, setItems] = useState(itemsList); - const maxIndex = items.length - 1; - - const onSwap = useCallback( - (name, direction) => { - const curPos = items.findIndex((val) => val === name); - const newPos = curPos + direction; - [items[curPos], items[newPos]] = [items[newPos], items[curPos]]; - onListChange(items); - setItems([...items]); - }, - [items] - ); - - return items.map((key, index) => { - const item = CONFIG.providers[key]; - item.name = key; - return react.createElement(ServiceOption, { - item, - key, - isFirst: index === 0, - isLast: index === maxIndex, - onSwap, - onTokenChange, - onToggle, - }); - }); +const ServiceList = ({ + itemsList, + onListChange = () => {}, + onToggle = () => {}, + onTokenChange = () => {}, +}) => { + const [items, setItems] = useState(itemsList); + const maxIndex = items.length - 1; + + const onSwap = useCallback( + (name, direction) => { + const curPos = items.findIndex((val) => val === name); + const newPos = curPos + direction; + [items[curPos], items[newPos]] = [items[newPos], items[curPos]]; + onListChange(items); + setItems([...items]); + }, + [items] + ); + + return items.map((key, index) => { + const item = CONFIG.providers[key]; + item.name = key; + return react.createElement(ServiceOption, { + item, + key, + isFirst: index === 0, + isLast: index === maxIndex, + onSwap, + onTokenChange, + onToggle, + }); + }); }; const corsProxyTemplate = () => { - const [proxyValue, setProxyValue] = react.useState(localStorage.getItem("spicetify:corsProxyTemplate") || "https://cors-proxy.spicetify.app/{url}"); - - return react.createElement("input", { - placeholder: "CORS Proxy Template", - value: proxyValue, - onChange: (event) => { - const value = event.target.value; - setProxyValue(value); - - if (value === "" || !value) return localStorage.removeItem("spicetify:corsProxyTemplate"); - localStorage.setItem("spicetify:corsProxyTemplate", value); - }, - }); + const [proxyValue, setProxyValue] = react.useState( + localStorage.getItem("spicetify:corsProxyTemplate") || + "https://cors-proxy.spicetify.app/{url}" + ); + + return react.createElement("input", { + placeholder: "CORS Proxy Template", + value: proxyValue, + onChange: (event) => { + const value = event.target.value; + setProxyValue(value); + + if (value === "" || !value) + return localStorage.removeItem("spicetify:corsProxyTemplate"); + localStorage.setItem("spicetify:corsProxyTemplate", value); + }, + }); }; const OptionList = ({ type, items, onChange }) => { - const [itemList, setItemList] = useState(items); - const [, forceUpdate] = useState(); - - useEffect(() => { - if (!type) return; - - const eventListener = (event) => { - if (event.detail?.type !== type) return; - setItemList(event.detail.items); - }; - document.addEventListener("lyrics-plus", eventListener); - - return () => document.removeEventListener("lyrics-plus", eventListener); - }, []); - - return itemList.map((item) => { - if (!item || (item.when && !item.when())) { - return; - } - - const onChangeItem = item.onChange || onChange; - - return react.createElement( - "div", - null, - react.createElement(item.type, { - ...item, - name: item.desc, - defaultValue: CONFIG.visual[item.key], - onChange: (value) => { - onChangeItem(item.key, value); - forceUpdate({}); - }, - }), - item.info && - react.createElement("span", { - dangerouslySetInnerHTML: { - __html: item.info, - }, - }) - ); - }); + const [itemList, setItemList] = useState(items); + const [, forceUpdate] = useState(); + + useEffect(() => { + if (!type) return; + + const eventListener = (event) => { + if (event.detail?.type !== type) return; + setItemList(event.detail.items); + }; + document.addEventListener("lyrics-plus", eventListener); + + return () => document.removeEventListener("lyrics-plus", eventListener); + }, []); + + return itemList.map((item) => { + if (!item || (item.when && !item.when())) { + return; + } + + const onChangeItem = item.onChange || onChange; + + return react.createElement( + "div", + null, + react.createElement(item.type, { + ...item, + name: item.desc, + defaultValue: CONFIG.visual[item.key], + onChange: (value) => { + onChangeItem(item.key, value); + forceUpdate({}); + }, + }), + item.info && + react.createElement("span", { + dangerouslySetInnerHTML: { + __html: item.info, + }, + }) + ); + }); }; function openConfig() { - const configContainer = react.createElement( - "div", - { - id: `${APP_NAME}-config-container`, - }, - react.createElement("h2", null, "Options"), - react.createElement(OptionList, { - items: [ - { - desc: "Playbar button", - key: "playbar-button", - info: "Replace Spotify's lyrics button with Lyrics Plus.", - type: ConfigSlider, - }, - { - desc: "Global delay", - info: "Offset (in ms) across all tracks.", - key: "global-delay", - type: ConfigAdjust, - min: -10000, - max: 10000, - step: 250, - }, - { - desc: "Font size", - info: "(or Ctrl + Mouse scroll in main app)", - key: "font-size", - type: ConfigAdjust, - min: fontSizeLimit.min, - max: fontSizeLimit.max, - step: fontSizeLimit.step, - }, - { - desc: "Alignment", - key: "alignment", - type: ConfigSelection, - options: { - left: "Left", - center: "Center", - right: "Right", - }, - }, - { - desc: "Fullscreen hotkey", - key: "fullscreen-key", - type: ConfigHotkey, - }, - { - desc: "Compact synced: Lines to show before", - key: "lines-before", - type: ConfigSelection, - options: [0, 1, 2, 3, 4], - }, - { - desc: "Compact synced: Lines to show after", - key: "lines-after", - type: ConfigSelection, - options: [0, 1, 2, 3, 4], - }, - { - desc: "Compact synced: Fade-out blur", - key: "fade-blur", - type: ConfigSlider, - }, - { - desc: "Noise overlay", - key: "noise", - type: ConfigSlider, - }, - { - desc: "Colorful background", - key: "colorful", - type: ConfigSlider, - }, - { - desc: "Background color", - key: "background-color", - type: ConfigInput, - when: () => !CONFIG.visual.colorful, - }, - { - desc: "Active text color", - key: "active-color", - type: ConfigInput, - when: () => !CONFIG.visual.colorful, - }, - { - desc: "Inactive text color", - key: "inactive-color", - type: ConfigInput, - when: () => !CONFIG.visual.colorful, - }, - { - desc: "Highlight text background", - key: "highlight-color", - type: ConfigInput, - when: () => !CONFIG.visual.colorful, - }, - { - desc: "Text convertion: Japanese Detection threshold (Advanced)", - info: "Checks if whenever Kana is dominant in lyrics. If the result passes the threshold, it's most likely Japanese, and vice versa. This setting is in percentage.", - key: "ja-detect-threshold", - type: ConfigAdjust, - min: thresholdSizeLimit.min, - max: thresholdSizeLimit.max, - step: thresholdSizeLimit.step, - }, - { - desc: "Text convertion: Traditional-Simplified Detection threshold (Advanced)", - info: "Checks if whenever Traditional or Simplified is dominant in lyrics. If the result passes the threshold, it's most likely Simplified, and vice versa. This setting is in percentage.", - key: "hans-detect-threshold", - type: ConfigAdjust, - min: thresholdSizeLimit.min, - max: thresholdSizeLimit.max, - step: thresholdSizeLimit.step, - }, - ], - onChange: (name, value) => { - CONFIG.visual[name] = value; - localStorage.setItem(`${APP_NAME}:visual:${name}`, value); - lyricContainerUpdate?.(); - - const configChange = new CustomEvent("lyrics-plus", { - detail: { - type: "config", - name: name, - value: value, - }, - }); - window.dispatchEvent(configChange); - }, - }), - react.createElement("h2", null, "Providers"), - react.createElement(ServiceList, { - itemsList: CONFIG.providersOrder, - onListChange: (list) => { - CONFIG.providersOrder = list; - localStorage.setItem(`${APP_NAME}:services-order`, JSON.stringify(list)); - }, - onToggle: (name, value) => { - CONFIG.providers[name].on = value; - localStorage.setItem(`${APP_NAME}:provider:${name}:on`, value); - lyricContainerUpdate?.(); - }, - onTokenChange: (name, value) => { - CONFIG.providers[name].token = value; - localStorage.setItem(`${APP_NAME}:provider:${name}:token`, value); - }, - }), - react.createElement("h2", null, "CORS Proxy Template"), - react.createElement("span", { - dangerouslySetInnerHTML: { - __html: - "Use this to bypass CORS restrictions. Replace the URL with your cors proxy server of your choice. {url} will be replaced with the request URL.", - }, - }), - react.createElement(corsProxyTemplate), - react.createElement("span", { - dangerouslySetInnerHTML: { - __html: "Spotify will reload its webview after applying. Leave empty to restore default: https://cors-proxy.spicetify.app/{url}", - }, - }) - ); - - Spicetify.PopupModal.display({ - title: "Lyrics Plus", - content: configContainer, - isLarge: true, - }); + const configContainer = react.createElement( + "div", + { + id: `${APP_NAME}-config-container`, + }, + react.createElement("h2", null, "Options"), + react.createElement(OptionList, { + items: [ + { + desc: "Playbar button", + key: "playbar-button", + info: "Replace Spotify's lyrics button with Lyrics Plus.", + type: ConfigSlider, + }, + { + desc: "Global delay", + info: "Offset (in ms) across all tracks.", + key: "global-delay", + type: ConfigAdjust, + min: -10000, + max: 10000, + step: 250, + }, + { + desc: "Font size", + info: "(or Ctrl + Mouse scroll in main app)", + key: "font-size", + type: ConfigAdjust, + min: fontSizeLimit.min, + max: fontSizeLimit.max, + step: fontSizeLimit.step, + }, + { + desc: "Alignment", + key: "alignment", + type: ConfigSelection, + options: { + left: "Left", + center: "Center", + right: "Right", + }, + }, + { + desc: "Fullscreen hotkey", + key: "fullscreen-key", + type: ConfigHotkey, + }, + { + desc: "Compact synced: Lines to show before", + key: "lines-before", + type: ConfigSelection, + options: [0, 1, 2, 3, 4], + }, + { + desc: "Compact synced: Lines to show after", + key: "lines-after", + type: ConfigSelection, + options: [0, 1, 2, 3, 4], + }, + { + desc: "Compact synced: Fade-out blur", + key: "fade-blur", + type: ConfigSlider, + }, + { + desc: "Noise overlay", + key: "noise", + type: ConfigSlider, + }, + { + desc: "Colorful background", + key: "colorful", + type: ConfigSlider, + }, + { + desc: "Background color", + key: "background-color", + type: ConfigInput, + when: () => !CONFIG.visual.colorful, + }, + { + desc: "Active text color", + key: "active-color", + type: ConfigInput, + when: () => !CONFIG.visual.colorful, + }, + { + desc: "Inactive text color", + key: "inactive-color", + type: ConfigInput, + when: () => !CONFIG.visual.colorful, + }, + { + desc: "Highlight text background", + key: "highlight-color", + type: ConfigInput, + when: () => !CONFIG.visual.colorful, + }, + { + desc: "Text convertion: Japanese Detection threshold (Advanced)", + info: "Checks if whenever Kana is dominant in lyrics. If the result passes the threshold, it's most likely Japanese, and vice versa. This setting is in percentage.", + key: "ja-detect-threshold", + type: ConfigAdjust, + min: thresholdSizeLimit.min, + max: thresholdSizeLimit.max, + step: thresholdSizeLimit.step, + }, + { + desc: "Text convertion: Traditional-Simplified Detection threshold (Advanced)", + info: "Checks if whenever Traditional or Simplified is dominant in lyrics. If the result passes the threshold, it's most likely Simplified, and vice versa. This setting is in percentage.", + key: "hans-detect-threshold", + type: ConfigAdjust, + min: thresholdSizeLimit.min, + max: thresholdSizeLimit.max, + step: thresholdSizeLimit.step, + }, + { + desc: "Musixmatch Translation Language.", + info: "Choose the language you want to translate the lyrics to. Changes will take effect after the next track.", + key: "musixmatch-translation-language", + type: ConfigSelection, + options: { + none: "None", + en: "English", + af: "Afrikaans", + ar: "Arabic", + bg: "Bulgarian", + bn: "Bengali", + ca: "Catalan", + cs: "Czech", + da: "Danish", + de: "German", + el: "Greek", + es: "Spanish", + et: "Estonian", + fa: "Persian", + fi: "Finnish", + fr: "French", + gu: "Gujarati", + he: "Hebrew", + hi: "Hindi", + hr: "Croatian", + hu: "Hungarian", + id: "Indonesian", + is: "Icelandic", + it: "Italian", + pt: "Portuguese", + ja: "Japanese", + jv: "Javanese", + kn: "Kannada", + ko: "Korean", + lt: "Lithuanian", + lv: "Latvian", + ml: "Malayalam", + mr: "Marathi", + ms: "Malay", + nl: "Dutch", + no: "Norwegian", + pl: "Polish", + pt: "Portuguese", + ro: "Romanian", + ru: "Russian", + sk: "Slovak", + sl: "Slovenian", + sr: "Serbian", + su: "Sundanese", + sv: "Swedish", + ta: "Tamil", + te: "Telugu", + th: "Thai", + tr: "Turkish", + uk: "Ukrainian", + ur: "Urdu", + vi: "Vietnamese", + zh: "Chinese", + zu: "Zulu", + }, + defaultValue: + localStorage.getItem( + `${APP_NAME}:visual:musixmatch-translation-language` + ) || "en", + }, + ], + onChange: (name, value) => { + CONFIG.visual[name] = value; + localStorage.setItem(`${APP_NAME}:visual:${name}`, value); + lyricContainerUpdate?.(); + + const configChange = new CustomEvent("lyrics-plus", { + detail: { + type: "config", + name: name, + value: value, + }, + }); + window.dispatchEvent(configChange); + }, + }), + react.createElement("h2", null, "Providers"), + react.createElement(ServiceList, { + itemsList: CONFIG.providersOrder, + onListChange: (list) => { + CONFIG.providersOrder = list; + localStorage.setItem( + `${APP_NAME}:services-order`, + JSON.stringify(list) + ); + }, + onToggle: (name, value) => { + CONFIG.providers[name].on = value; + localStorage.setItem(`${APP_NAME}:provider:${name}:on`, value); + lyricContainerUpdate?.(); + }, + onTokenChange: (name, value) => { + CONFIG.providers[name].token = value; + localStorage.setItem(`${APP_NAME}:provider:${name}:token`, value); + }, + }), + react.createElement("h2", null, "CORS Proxy Template"), + react.createElement("span", { + dangerouslySetInnerHTML: { + __html: + "Use this to bypass CORS restrictions. Replace the URL with your cors proxy server of your choice. {url} will be replaced with the request URL.", + }, + }), + react.createElement(corsProxyTemplate), + react.createElement("span", { + dangerouslySetInnerHTML: { + __html: + "Spotify will reload its webview after applying. Leave empty to restore default: https://cors-proxy.spicetify.app/{url}", + }, + }) + ); + + Spicetify.PopupModal.display({ + title: "Lyrics Plus", + content: configContainer, + isLarge: true, + }); } + +CONFIG.visual["musixmatch-translation-language"] = + localStorage.getItem(`${APP_NAME}:visual:musixmatch-translation-language`) || + "none";