diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5b561fc02d..4179fe6655 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -83,6 +83,31 @@ jobs:
subject-path: "./spicetify${{ matrix.os == 'windows' && '.exe' || '' }}"
subject-name: "spicetify v${{ env.TAG }} (${{ matrix.os }}, ${{ (matrix.os == 'windows' && matrix.arch == 'amd64' && 'x64') || (matrix.os == 'windows' && matrix.arch == '386' && 'x32') || matrix.arch }})"
+ - name: Upload Artifact for Signing
+ if: env.IS_WIN == 'true'
+ id: upload-artifact-for-signing
+ uses: actions/upload-artifact@v4
+ with:
+ name: spicetify-${{ env.TAG }}-${{ matrix.os }}-${{ (matrix.arch == 'amd64' && 'x64') || (matrix.arch == 'arm64' && 'arm64') || 'x32' }}-unsigned
+ path: ./spicetify.exe
+
+ - name: Sign Windows Executable
+ if: env.IS_WIN == 'true'
+ uses: signpath/github-action-submit-signing-request@v1
+ with:
+ api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+ organization-id: ${{ secrets.SIGNPATH_ORG_ID }}
+ project-slug: "cli"
+ signing-policy-slug: "release-signing"
+ github-artifact-id: ${{ steps.upload-artifact-for-signing.outputs.artifact-id }}
+ wait-for-completion: true
+ output-artifact-directory: "./signed"
+
+ - name: Copy Signed Windows Executable
+ if: env.IS_WIN == 'true'
+ run: |
+ cp ./signed/spicetify.exe ./spicetify.exe
+
- name: 7z - .tar
if: env.IS_UNIX == 'true'
uses: edgarrc/action-7z@v1
diff --git a/CustomApps/lyrics-plus/OptionsMenu.js b/CustomApps/lyrics-plus/OptionsMenu.js
index 30e986599d..1dfaf49b8a 100644
--- a/CustomApps/lyrics-plus/OptionsMenu.js
+++ b/CustomApps/lyrics-plus/OptionsMenu.js
@@ -92,6 +92,11 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
none: "None",
};
+ const translationDisplayOptions = {
+ replace: "Replace original",
+ below: "Below original",
+ };
+
const languageOptions = {
off: "Off",
"zh-hans": "Chinese (Simplified)",
@@ -100,12 +105,19 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
ko: "Korean",
};
- let modeOptions = {};
+ let modeOptions = {
+ none: "None",
+ };
if (hasTranslation.musixmatch) {
+ const selectedLanguage = CONFIG.visual["musixmatch-translation-language"];
+ if (selectedLanguage === "none") return;
+ const languageName = new Intl.DisplayNames([selectedLanguage], {
+ type: "language",
+ }).of(selectedLanguage);
sourceOptions = {
...sourceOptions,
- musixmatchTranslation: "English (Musixmatch)",
+ musixmatchTranslation: `${languageName} (Musixmatch)`,
};
}
@@ -128,7 +140,6 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
}
case "korean": {
modeOptions = {
- hangul: "Hangul",
romaja: "Romaja",
};
break;
@@ -151,12 +162,21 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
options: sourceOptions,
renderInline: true,
},
+ {
+ desc: "Translation Display",
+ key: "translate:display-mode",
+ type: ConfigSelection,
+ options: translationDisplayOptions,
+ renderInline: true,
+ },
{
desc: "Language Override",
key: "translate:detect-language-override",
type: ConfigSelection,
options: languageOptions,
renderInline: true,
+ // for songs in languages that support translation but not Convert (e.g., English), the option is disabled.
+ when: () => friendlyLanguage,
},
{
desc: "Display Mode",
@@ -164,6 +184,8 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
type: ConfigSelection,
options: modeOptions,
renderInline: true,
+ // for songs in languages that support translation but not Convert (e.g., English), the option is disabled.
+ when: () => friendlyLanguage,
},
{
desc: "Convert",
@@ -172,6 +194,8 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
trigger: "click",
action: "toggle",
renderInline: true,
+ // for songs in languages that support translation but not Convert (e.g., English), the option is disabled.
+ when: () => friendlyLanguage,
},
];
}, [friendlyLanguage]);
@@ -209,6 +233,15 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
type: "translation-menu",
items,
onChange: (name, value) => {
+ if (name === "translate:translated-lyrics-source" && friendlyLanguage) {
+ CONFIG.visual.translate = false;
+ localStorage.setItem(`${APP_NAME}:visual:translate`, false);
+ }
+ if (name === "translate") {
+ CONFIG.visual["translate:translated-lyrics-source"] = "none";
+ localStorage.setItem(`${APP_NAME}:visual:translate:translated-lyrics-source`, "none");
+ }
+
CONFIG.visual[name] = value;
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);
lyricContainerUpdate?.();
diff --git a/CustomApps/lyrics-plus/Pages.js b/CustomApps/lyrics-plus/Pages.js
index 5f99b25156..1b960236f6 100755
--- a/CustomApps/lyrics-plus/Pages.js
+++ b/CustomApps/lyrics-plus/Pages.js
@@ -123,8 +123,6 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara
offset += -(activeLineEle.current.offsetTop + activeLineEle.current.clientHeight / 2);
}
- const rawLyrics = Utils.convertParsedToLRC(lyrics);
-
return react.createElement(
"div",
{
@@ -140,7 +138,7 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara
},
key: lyricsId,
},
- activeLines.map(({ text, lineNumber, startTime }, i) => {
+ activeLines.map(({ text, lineNumber, startTime, originalText }, i) => {
if (i === 1 && activeLineIndex === 1) {
return react.createElement(IdlingIndicator, {
progress: position / activeLines[2].startTime,
@@ -169,9 +167,19 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara
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 = (typeof originalText === "object" ? originalText?.props?.children?.[0] : originalText)?.replace(/\s+/g, "");
+ const belowTxt = (typeof text === "object" ? text?.props?.children?.[0] : text)?.replace(/\s+/g, "");
+
+ const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt;
return react.createElement(
- "p",
+ "div",
{
className,
style: {
@@ -180,22 +188,43 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara
"--animation-index": (animationIndex < 0 ? 0 : animationIndex) + 1,
"--blur-index": Math.abs(animationIndex),
},
- key: lineNumber,
dir: "auto",
ref,
+ key: lineNumber,
onClick: (event) => {
if (startTime) {
Spicetify.Player.seek(startTime);
}
},
- onContextMenu: (event) => {
- event.preventDefault();
- Spicetify.Platform.ClipboardAPI.copy(rawLyrics)
- .then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
- .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
- },
},
- !isKara ? text : react.createElement(KaraokeLine, { text, startTime, position, isActive })
+ react.createElement(
+ "p",
+ {
+ onContextMenu: (event) => {
+ event.preventDefault();
+ Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).original)
+ .then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
+ .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
+ },
+ },
+ !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, position, isActive })
+ ),
+ belowMode &&
+ react.createElement(
+ "p",
+ {
+ style: {
+ opacity: 0.5,
+ },
+ onContextMenu: (event) => {
+ event.preventDefault();
+ Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).conver)
+ .then(() => Spicetify.showNotification("Translated lyrics copied to clipboard"))
+ .catch(() => Spicetify.showNotification("Failed to copy translated lyrics to clipboard"));
+ },
+ },
+ text
+ )
);
})
),
@@ -389,8 +418,6 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa
}
}
- const rawLyrics = Utils.convertParsedToLRC(lyrics);
-
useEffect(() => {
if (activeLineRef.current && (!intialScroll[0] || isInViewport(activeLineRef.current))) {
activeLineRef.current.scrollIntoView({
@@ -412,7 +439,7 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa
react.createElement("p", {
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding",
}),
- padded.map(({ text, startTime }, i) => {
+ padded.map(({ text, startTime, originalText }, i) => {
if (i === 0) {
return react.createElement(IdlingIndicator, {
isActive: activeLineIndex === 0,
@@ -422,10 +449,22 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa
}
const isActive = i === activeLineIndex;
+ 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 = (typeof originalText === "object" ? originalText?.props?.children?.[0] : originalText)?.replace(/\s+/g, "");
+ const belowTxt = (typeof text === "object" ? text?.props?.children?.[0] : text)?.replace(/\s+/g, "");
+
+ const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt;
+
return react.createElement(
- "p",
+ "div",
{
className: `lyrics-lyricsContainer-LyricsLine${i <= activeLineIndex ? " lyrics-lyricsContainer-LyricsLine-active" : ""}`,
+ key: i,
style: {
cursor: "pointer",
},
@@ -436,14 +475,33 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa
Spicetify.Player.seek(startTime);
}
},
- onContextMenu: (event) => {
- event.preventDefault();
- Spicetify.Platform.ClipboardAPI.copy(rawLyrics)
- .then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
- .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
- },
},
- !isKara ? text : react.createElement(KaraokeLine, { text, startTime, position, isActive })
+ react.createElement(
+ "p",
+ {
+ onContextMenu: (event) => {
+ event.preventDefault();
+ Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).original)
+ .then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
+ .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
+ },
+ },
+ !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, position, isActive })
+ ),
+ belowMode &&
+ react.createElement(
+ "p",
+ {
+ style: { opacity: 0.5 },
+ onContextMenu: (event) => {
+ event.preventDefault();
+ Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToLRC(lyrics, belowMode).conver)
+ .then(() => Spicetify.showNotification("Translated lyrics copied to clipboard"))
+ .catch(() => Spicetify.showNotification("Failed to copy translated lyrics to clipboard"));
+ },
+ },
+ text
+ )
);
}),
react.createElement("p", {
@@ -458,8 +516,6 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa
});
const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => {
- const rawLyrics = lyrics.map((lyrics) => (typeof lyrics.text !== "object" ? lyrics.text : lyrics.text?.props?.children?.[0])).join("\n");
-
return react.createElement(
"div",
{
@@ -468,20 +524,51 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => {
react.createElement("p", {
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding",
}),
- lyrics.map(({ text }) => {
+ lyrics.map(({ text, originalText }, index) => {
+ 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 = (typeof originalText === "object" ? originalText?.props?.children?.[0] : originalText)?.replace(/\s+/g, "");
+ const belowTxt = (typeof text === "object" ? text?.props?.children?.[0] : text)?.replace(/\s+/g, "");
+
+ const belowMode = showTranslatedBelow && originalText && belowOrigin !== belowTxt;
+
return react.createElement(
- "p",
+ "div",
{
className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active",
+ key: index,
dir: "auto",
- onContextMenu: (event) => {
- event.preventDefault();
- Spicetify.Platform.ClipboardAPI.copy(rawLyrics)
- .then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
- .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
- },
},
- text
+ react.createElement(
+ "p",
+ {
+ onContextMenu: (event) => {
+ event.preventDefault();
+ Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToUnsynced(lyrics, belowMode).original)
+ .then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
+ .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
+ },
+ },
+ lineText
+ ),
+ belowMode &&
+ react.createElement(
+ "p",
+ {
+ style: { opacity: 0.5 },
+ onContextMenu: (event) => {
+ event.preventDefault();
+ Spicetify.Platform.ClipboardAPI.copy(Utils.convertParsedToUnsynced(lyrics, belowMode).conver)
+ .then(() => Spicetify.showNotification("Translated lyrics copied to clipboard"))
+ .catch(() => Spicetify.showNotification("Failed to copy translated lyrics to clipboard"));
+ },
+ },
+ text
+ )
);
}),
react.createElement("p", {
diff --git a/CustomApps/lyrics-plus/ProviderMusixmatch.js b/CustomApps/lyrics-plus/ProviderMusixmatch.js
index bee377b9a5..92db4dfbcd 100644
--- a/CustomApps/lyrics-plus/ProviderMusixmatch.js
+++ b/CustomApps/lyrics-plus/ProviderMusixmatch.js
@@ -162,11 +162,15 @@ const ProviderMusixmatch = (() => {
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&selected_language=en&comment_format=text&format=json&app_id=web-desktop-app-v1.0&";
+ "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,
};
@@ -184,7 +188,10 @@ const ProviderMusixmatch = (() => {
if (!result.translations_list?.length) return null;
- return result.translations_list.map(({ translation }) => ({ translation: translation.description, matchedLine: translation.matched_line }));
+ 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/Providers.js b/CustomApps/lyrics-plus/Providers.js
index 22224983a8..d542253cea 100644
--- a/CustomApps/lyrics-plus/Providers.js
+++ b/CustomApps/lyrics-plus/Providers.js
@@ -36,7 +36,10 @@ const Providers = {
}));
}
- result.provider = lyrics.provider;
+ /**
+ * to distinguish it from the existing Musixmatch, the provider will remain as Spotify.
+ * if Spotify official lyrics support multiple providers besides Musixmatch in the future, please uncomment the under section. */
+ // result.provider = lyrics.provider;
return result;
},
@@ -122,8 +125,13 @@ const Providers = {
result.unsynced = unsynced;
}
const translation = ProviderNetease.getTranslation(list);
- if (translation) {
- result.neteaseTranslation = translation;
+ if ((synced || unsynced) && Array.isArray(translation)) {
+ const baseLyrics = synced ?? unsynced;
+ result.neteaseTranslation = baseLyrics.map((line) => ({
+ ...line,
+ text: translation.find((t) => t.startTime === line.startTime)?.text ?? line.text,
+ originalText: line.text,
+ }));
}
return result;
diff --git a/CustomApps/lyrics-plus/README.md b/CustomApps/lyrics-plus/README.md
index e2b9401298..f5590d87bc 100644
--- a/CustomApps/lyrics-plus/README.md
+++ b/CustomApps/lyrics-plus/README.md
@@ -7,7 +7,7 @@ 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.
+- Genius: Provides unsynced lyrics but with description/insight from artists themselves (Disabled and cannot be used as a provider on `1.2.31` and higher).

diff --git a/CustomApps/lyrics-plus/Settings.js b/CustomApps/lyrics-plus/Settings.js
index 80109ec889..84ad23278b 100644
--- a/CustomApps/lyrics-plus/Settings.js
+++ b/CustomApps/lyrics-plus/Settings.js
@@ -51,7 +51,7 @@ const CacheButton = () => {
}
const [count, setCount] = useState(Object.keys(lyrics).length);
- const text = count ? "Clear cached lyrics" : "No cached lyrics";
+ const text = count ? "Clear all cached lyrics" : "No cached lyrics";
return react.createElement(
"button",
@@ -106,9 +106,43 @@ const RefreshTokenButton = ({ setTokenCallback }) => {
);
};
+const ConfigButton = ({ name, text, onChange = () => {} }) => {
+ return react.createElement(
+ "div",
+ {
+ className: "setting-row",
+ },
+ react.createElement(
+ "label",
+ {
+ className: "col description",
+ },
+ name
+ ),
+ react.createElement(
+ "div",
+ {
+ className: "col action",
+ },
+ react.createElement(
+ "button",
+ {
+ className: "btn",
+ onClick: onChange,
+ },
+ text
+ )
+ )
+ );
+};
+
const ConfigSlider = ({ name, defaultValue, onChange = () => {} }) => {
const [active, setActive] = useState(defaultValue);
+ useEffect(() => {
+ setActive(defaultValue);
+ }, [defaultValue]);
+
const toggleState = useCallback(() => {
const state = !active;
setActive(state);
@@ -513,6 +547,17 @@ const OptionList = ({ type, items, onChange }) => {
});
};
+const languageCodes =
+ "none,en,af,ar,bg,bn,ca,zh,cs,da,de,el,es,et,fa,fi,fr,gu,he,hi,hr,hu,id,is,it,ja,jv,kn,ko,lt,lv,ml,mr,ms,nl,no,pl,pt,ro,ru,sk,sl,sr,su,sv,ta,te,th,tr,uk,ur,vi,zu".split(
+ ","
+ );
+
+const displayNames = new Intl.DisplayNames(["en"], { type: "language" });
+const languageOptions = languageCodes.reduce((acc, code) => {
+ acc[code] = code === "none" ? "None" : displayNames.of(code);
+ return acc;
+}, {});
+
function openConfig() {
const configContainer = react.createElement(
"div",
@@ -630,11 +675,38 @@ function openConfig() {
max: thresholdSizeLimit.max,
step: thresholdSizeLimit.step,
},
+ {
+ desc: "Musixmatch Translation Language.",
+ info: "Choose the language you want to translate the lyrics to. When the language is changed, the lyrics reloads.",
+ key: "musixmatch-translation-language",
+ type: ConfigSelection,
+ options: languageOptions,
+ },
+ {
+ desc: "Clear Memory Cache",
+ info: "Loaded lyrics are cached in memory for faster reloading. Press this button to clear the cached lyrics from memory without restarting Spotify.",
+ key: "clear-memore-cache",
+ text: "Clear memory cache",
+ type: ConfigButton,
+ onChange: () => {
+ reloadLyrics?.();
+ },
+ },
],
onChange: (name, value) => {
CONFIG.visual[name] = value;
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);
- lyricContainerUpdate?.();
+
+ // Reload Lyrics if translation language is changed
+ if (name === "musixmatch-translation-language") {
+ if (value === "none") {
+ CONFIG.visual["translate:translated-lyrics-source"] = "none";
+ localStorage.setItem(`${APP_NAME}:visual:translate:translated-lyrics-source`, "none");
+ }
+ reloadLyrics?.();
+ } else {
+ lyricContainerUpdate?.();
+ }
const configChange = new CustomEvent("lyrics-plus", {
detail: {
@@ -652,15 +724,17 @@ function openConfig() {
onListChange: (list) => {
CONFIG.providersOrder = list;
localStorage.setItem(`${APP_NAME}:services-order`, JSON.stringify(list));
+ reloadLyrics?.();
},
onToggle: (name, value) => {
CONFIG.providers[name].on = value;
localStorage.setItem(`${APP_NAME}:provider:${name}:on`, value);
- lyricContainerUpdate?.();
+ reloadLyrics?.();
},
onTokenChange: (name, value) => {
CONFIG.providers[name].token = value;
localStorage.setItem(`${APP_NAME}:provider:${name}:token`, value);
+ reloadLyrics?.();
},
}),
react.createElement("h2", null, "CORS Proxy Template"),
diff --git a/CustomApps/lyrics-plus/TabBar.js b/CustomApps/lyrics-plus/TabBar.js
index b807edeb27..0f4746e5a6 100644
--- a/CustomApps/lyrics-plus/TabBar.js
+++ b/CustomApps/lyrics-plus/TabBar.js
@@ -63,8 +63,9 @@ const TabBarMore = react.memo(({ items, switchTo, lockIn }) => {
});
const TopBarContent = ({ links, activeLink, lockLink, switchCallback, lockCallback }) => {
- const resizeHost =
- document.querySelector(".Root__main-view .os-resize-observer-host") ?? document.querySelector(".Root__main-view .os-size-observer");
+ const resizeHost = document.querySelector(
+ ".Root__main-view .os-resize-observer-host, .Root__main-view .os-size-observer, .Root__main-view .main-view-container__scroll-node"
+ );
const [windowSize, setWindowSize] = useState(resizeHost.clientWidth);
const resizeHandler = () => setWindowSize(resizeHost.clientWidth);
diff --git a/CustomApps/lyrics-plus/Translator.js b/CustomApps/lyrics-plus/Translator.js
index b012379d06..453c0f3d73 100644
--- a/CustomApps/lyrics-plus/Translator.js
+++ b/CustomApps/lyrics-plus/Translator.js
@@ -6,12 +6,13 @@ const openCCPath = "https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/dist/umd/full.m
const dictPath = "https:/cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict";
class Translator {
- constructor(lang) {
+ constructor(lang, isUsingNetease = false) {
this.finished = {
ja: false,
ko: false,
zh: false,
};
+ this.isUsingNetease = isUsingNetease;
this.applyKuromojiFix();
this.injectExternals(lang);
@@ -19,7 +20,7 @@ class Translator {
}
includeExternal(url) {
- if (CONFIG.visual.translate && !document.querySelector(`script[src="${url}"]`)) {
+ if ((CONFIG.visual.translate || this.isUsingNetease) && !document.querySelector(`script[src="${url}"]`)) {
const script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", url);
@@ -42,6 +43,21 @@ class Translator {
}
}
+ async awaitFinished(language) {
+ return new Promise((resolve) => {
+ const interval = setInterval(() => {
+ this.injectExternals(language);
+ this.createTranslator(language);
+
+ const lan = language.slice(0, 2);
+ if (this.finished[lan]) {
+ clearInterval(interval);
+ resolve();
+ }
+ }, 100);
+ });
+ }
+
/**
* Fix an issue with kuromoji when loading dict from external urls
* Adapted from: https://github.com/mobilusoss/textlint-browser-runner/pull/7
diff --git a/CustomApps/lyrics-plus/Utils.js b/CustomApps/lyrics-plus/Utils.js
index bd3961f587..39c28f8e8f 100644
--- a/CustomApps/lyrics-plus/Utils.js
+++ b/CustomApps/lyrics-plus/Utils.js
@@ -70,7 +70,7 @@ const Utils = {
*/
async toSimplifiedChinese(s) {
// create a singleton Translator instance
- if (!this._translatorInstance) this.translator = new Translator("zh");
+ if (!this._translatorInstance) this.translator = new Translator("zh", true);
// translate to Simplified Chinese
// as Traditional Chinese differs between HK and TW, forcing to use OpenCC standard
@@ -96,7 +96,7 @@ const Utils = {
// Should return IETF BCP 47 language tags.
// This should detect the song's main language.
// Remember there is a possibility of a song referencing something in another language and the lyrics show it in that native language!
- const rawLyrics = lyrics.map((line) => line.text).join(" ");
+ const rawLyrics = lyrics[0].originalText ? lyrics.map((line) => line.originalText).join(" ") : lyrics.map((line) => line.text).join(" ");
const kanaRegex = /[\u3001-\u3003]|[\u3005\u3007]|[\u301d-\u301f]|[\u3021-\u3035]|[\u3038-\u303a]|[\u3040-\u30ff]|[\uff66-\uff9f]/gu;
const hangulRegex = /(\S*[\u3131-\u314e|\u314f-\u3163|\uac00-\ud7a3]+\S*)/g;
@@ -132,17 +132,14 @@ const Utils = {
return ((simpPercentage - tradPercentage + 1) / 2) * 100 >= CONFIG.visual["hans-detect-threshold"] ? "zh-hans" : "zh-hant";
},
- processTranslatedLyrics(result, lyricsToTranslate, { state, stateName }) {
- const translatedLines = result.split("\n");
- state[stateName] = [];
- for (let i = 0; i < lyricsToTranslate.length; i++) {
- const lyric = {
- startTime: lyricsToTranslate[i].startTime || 0,
- text: this.rubyTextToReact(translatedLines[i]),
- };
- state[stateName].push(lyric);
- }
+ processTranslatedLyrics(translated, original) {
+ return original.map((lyric, index) => ({
+ startTime: lyric.startTime || 0,
+ text: this.rubyTextToReact(translated[index]),
+ originalText: lyric.text,
+ }));
},
+ /** It seems that this function is not being used, but I'll keep it just in case it’s needed in the future.*/
processTranslatedOriginalLyrics(lyrics, synced) {
const data = [];
const dataSouce = {};
@@ -222,13 +219,58 @@ const Utils = {
}
return text;
},
- convertParsedToLRC(lyrics) {
- return lyrics
- .map((line) => {
- if (!line.startTime) return line.text;
- return `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.text, line.startTime)}`;
- })
- .join("\n");
+ convertParsedToLRC(lyrics, isBelow) {
+ let original = "";
+ let conver = "";
+
+ if (isBelow) {
+ for (const line of lyrics) {
+ original += `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.originalText, line.startTime)}\n`;
+ conver += `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.text, line.startTime)}\n`;
+ }
+ } else {
+ for (const line of lyrics) {
+ original += `[${this.formatTime(line.startTime)}]${this.formatTextWithTimestamps(line.text, line.startTime)}\n`;
+ }
+ }
+
+ return {
+ original,
+ conver,
+ };
+ },
+ convertParsedToUnsynced(lyrics, isBelow) {
+ let original = "";
+ let conver = "";
+
+ if (isBelow) {
+ for (const line of lyrics) {
+ if (typeof line.originalText === "object") {
+ original += `${line.originalText?.props?.children?.[0]}\n`;
+ } else {
+ original += `${line.originalText}\n`;
+ }
+
+ if (typeof line.text === "object") {
+ conver += `${line.text?.props?.children?.[0]}\n`;
+ } else {
+ conver += `${line.text}\n`;
+ }
+ }
+ } else {
+ for (const line of lyrics) {
+ if (typeof line.text === "object") {
+ original += `${line.text?.props?.children?.[0]}\n`;
+ } else {
+ original += `${line.text}\n`;
+ }
+ }
+ }
+
+ return {
+ original,
+ conver,
+ };
},
parseLocalLyrics(lyrics) {
// Preprocess lyrics by removing [tags] and empty lines
diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js
index f703e16a35..72952a6901 100644
--- a/CustomApps/lyrics-plus/index.js
+++ b/CustomApps/lyrics-plus/index.js
@@ -41,13 +41,15 @@ const CONFIG = {
"lines-after": localStorage.getItem("lyrics-plus:visual:lines-after") || "2",
"font-size": localStorage.getItem("lyrics-plus:visual:font-size") || "32",
"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") || "hangul",
+ "translation-mode:korean": localStorage.getItem("lyrics-plus:visual:translation-mode:korean") || "romaja",
"translation-mode:chinese": localStorage.getItem("lyrics-plus:visual:translation-mode:chinese") || "cn",
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",
+ "musixmatch-translation-language": localStorage.getItem("lyrics-plus:visual:musixmatch-translation-language") || "none",
"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"),
@@ -56,9 +58,14 @@ const CONFIG = {
delay: 0,
},
providers: {
+ lrclib: {
+ on: getConfig("lyrics-plus:provider:lrclib:on"),
+ desc: "Lyrics sourced from lrclib.net. Supports both synced and unsynced lyrics. LRCLIB is a free and open-source lyrics provider.",
+ modes: [SYNCED, UNSYNCED],
+ },
musixmatch: {
on: getConfig("lyrics-plus:provider:musixmatch:on"),
- desc: "Fully compatible with Spotify. Requires a token that can be retrieved from the official Musixmatch app. If you have problems with retrieving lyrics, try refreshing the token by clicking Refresh Token
button.",
+ desc: "Fully compatible with Spotify. Requires a token that can be retrieved from the official Musixmatch app. If you have problems with retrieving lyrics, try refreshing the token by clicking Refresh Token
button. You may need to be forced to use your own CORS Proxy to use this provider.",
token: localStorage.getItem("lyrics-plus:provider:musixmatch:token") || "21051986b9886beabe1ce01c3ce94c96319411f8f2c122676365e3",
modes: [KARAOKE, SYNCED, UNSYNCED],
},
@@ -68,15 +75,10 @@ const CONFIG = {
modes: [SYNCED, UNSYNCED],
},
netease: {
- on: getConfig("lyrics-plus:provider:netease:on"),
+ on: getConfig("lyrics-plus:provider:netease:on", false),
desc: "Crowdsourced lyrics provider ran by Chinese developers and users.",
modes: [KARAOKE, SYNCED, UNSYNCED],
},
- lrclib: {
- on: getConfig("lyrics-plus:provider:lrclib:on"),
- desc: "Lyrics sourced from lrclib.net. Supports both synced and unsynced lyrics. LRCLIB is a free and open-source lyrics provider.",
- modes: [SYNCED, UNSYNCED],
- },
genius: {
on: spotifyVersion >= "1.2.31" ? false : getConfig("lyrics-plus:provider:genius:on"),
desc: "Provide unsynced lyrics with insights from artists themselves. Genius is disabled and cannot be used as a provider on 1.2.31
and higher.",
@@ -110,7 +112,7 @@ CONFIG.visual["font-size"] = Number.parseInt(CONFIG.visual["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"]);
-const CACHE = {};
+let CACHE = {};
const emptyState = {
karaoke: null,
@@ -122,6 +124,7 @@ const emptyState = {
};
let lyricContainerUpdate;
+let reloadLyrics;
const fontSizeLimit = { min: 16, max: 256, step: 4 };
@@ -164,6 +167,7 @@ class LyricsContainer extends react.Component {
isFullscreen: false,
isFADMode: false,
isCached: false,
+ language: null,
};
this.currentTrackUri = "";
this.nextTrackUri = "";
@@ -171,13 +175,15 @@ class LyricsContainer extends react.Component {
this.styleVariables = {};
this.fullscreenContainer = document.createElement("div");
this.fullscreenContainer.id = "lyrics-fullscreen-container";
- this.mousetrap = new Spicetify.Mousetrap();
+ this.mousetrap = null;
this.containerRef = react.createRef(null);
- this.translator = new Translator(CONFIG.visual["translate:detect-language-override"]);
+ this.translator = null;
+ this.initMoustrap();
// Cache last state
- this.translationProvider = CONFIG.visual["translate:translated-lyrics-source"];
this.languageOverride = CONFIG.visual["translate:detect-language-override"];
this.translate = CONFIG.visual.translate;
+ this.reRenderLyricsPage = false;
+ this.displayMode = null;
}
infoFromTrack(track) {
@@ -258,7 +264,6 @@ class LyricsContainer extends react.Component {
if (data.error || (!data.karaoke && !data.synced && !data.unsynced && !data.genius)) continue;
if (mode === -1) {
finalData = data;
- CACHE[data.uri] = finalData;
return finalData;
}
@@ -290,27 +295,13 @@ class LyricsContainer extends react.Component {
}));
}
- CACHE[data.uri] = finalData;
return finalData;
}
- CACHE[trackInfo.uri] = finalData;
return finalData;
}
- async fetchLyrics(track, mode = -1) {
- this.state.furigana =
- this.state.romaji =
- this.state.hiragana =
- this.state.katakana =
- this.state.hangul =
- this.state.romaja =
- this.state.cn =
- this.state.hk =
- this.state.tw =
- this.state.musixmatchTranslation =
- this.state.neteaseTranslation =
- null;
+ async fetchLyrics(track, mode = -1, refresh = false) {
const info = this.infoFromTrack(track);
if (!info) {
this.setState({ error: "No track info" });
@@ -324,161 +315,229 @@ class LyricsContainer extends react.Component {
}
this.fetchTempo(info.uri);
-
- if (mode !== -1) {
- if (CACHE[info.uri]?.[CONFIG.modes[mode]]) {
- this.resetDelay();
- this.setState({ ...CACHE[info.uri], isCached });
- {
- let mode = -1;
- if (this.state.explicitMode !== -1) {
- mode = this.state.explicitMode;
- } else if (this.state.lockMode !== -1) {
- mode = this.state.lockMode;
- } else {
- // Auto switch
- if (this.state.karaoke) {
- mode = KARAOKE;
- } else if (this.state.synced) {
- mode = SYNCED;
- } else if (this.state.unsynced) {
- mode = UNSYNCED;
- } else if (this.state.genius) {
- mode = GENIUS;
- }
- }
- const lyricsState = CACHE[info.uri][CONFIG.modes[mode]];
- if (lyricsState) {
- this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState;
- }
- }
- this.translateLyrics();
- return;
+ this.resetDelay();
+
+ let tempState;
+ // if lyrics are cached
+ if ((mode === -1 && CACHE[info.uri]) || CACHE[info.uri]?.[CONFIG.modes?.[mode]]) {
+ tempState = { ...CACHE[info.uri], isCached };
+ if (CACHE[info.uri]?.mode) {
+ this.state.explicitMode = CACHE[info.uri]?.mode;
+ tempState = { ...tempState, mode: CACHE[info.uri]?.mode };
}
} else {
- if (CACHE[info.uri]) {
- this.resetDelay();
- this.setState({ ...CACHE[info.uri], isCached });
- {
- let mode = -1;
- if (this.state.explicitMode !== -1) {
- mode = this.state.explicitMode;
- } else if (this.state.lockMode !== -1) {
- mode = this.state.lockMode;
- } else {
- // Auto switch
- if (this.state.karaoke) {
- mode = KARAOKE;
- } else if (this.state.synced) {
- mode = SYNCED;
- } else if (this.state.unsynced) {
- mode = UNSYNCED;
- } else if (this.state.genius) {
- mode = GENIUS;
- }
- }
- const lyricsState = CACHE[info.uri][CONFIG.modes[mode]];
- if (lyricsState) {
- this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState;
- }
- }
- this.translateLyrics();
+ this.setState({ ...emptyState, isLoading: true, isCached: false });
+
+ const resp = await this.tryServices(info, mode);
+ if (resp.provider) {
+ // Cache lyrics
+ CACHE[resp.uri] = resp;
+ }
+
+ // This True when the user presses the Cache Lyrics button and saves it to localStorage.
+ isCached = this.lyricsSaved(resp.uri);
+
+ // In case user skips tracks too fast and multiple callbacks
+ // set wrong lyrics to current track.
+ if (resp.uri === this.currentTrackUri) {
+ tempState = { ...resp, isLoading: false, isCached };
+ } else {
return;
}
}
- this.setState({ ...emptyState, isLoading: true, isCached: false });
- const resp = await this.tryServices(info, mode);
+ let finalMode = mode;
+ if (mode === -1) {
+ if (this.state.explicitMode !== -1) {
+ finalMode = this.state.explicitMode;
+ } else if (this.state.lockMode !== -1) {
+ finalMode = this.state.lockMode;
+ } else {
+ // Auto switch
+ if (tempState.karaoke) {
+ finalMode = KARAOKE;
+ } else if (tempState.synced) {
+ finalMode = SYNCED;
+ } else if (tempState.unsynced) {
+ finalMode = UNSYNCED;
+ } else if (tempState.genius) {
+ finalMode = GENIUS;
+ }
+ }
+ }
- isCached = this.lyricsSaved(resp.uri);
+ this.lyricsSource(tempState, finalMode);
+
+ // if song changed one time
+ if (tempState.uri !== this.state.uri || refresh) {
+ // when a song starts for the first time and language-override is selected, the lyrics are converted to the specified language.
+ // however, when switching it off again, the detected language needs to be known, so defaultLanguage has been introduced.
+ const defaultLanguage = Utils.detectLanguage(this.state.currentLyrics);
+ const language =
+ CONFIG.visual["translate:detect-language-override"] !== "off" ? CONFIG.visual["translate:detect-language-override"] : defaultLanguage;
+ const friendlyLanguage = language && new Intl.DisplayNames(["en"], { type: "language" }).of(language.split("-")[0])?.toLowerCase();
+ const targetConvert = CONFIG.visual[`translation-mode:${friendlyLanguage}`];
+
+ const isMemorey = CACHE[tempState.uri]?.[targetConvert];
+ if (CONFIG.visual.translate && defaultLanguage && !isMemorey) {
+ this.translateLyrics(language, this.state.currentLyrics, targetConvert).then((translated) => {
+ const res = { [targetConvert]: translated };
+ // Cache translated lyrics
+ CACHE[tempState.uri] = { ...CACHE[tempState.uri], ...res };
+ this.setState({ ...res });
+ });
+ }
- // 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, isCached });
+ // reset and apply
+ this.setState({
+ furigana: null,
+ romaji: null,
+ hiragana: null,
+ katakana: null,
+ hangul: null,
+ romaja: null,
+ cn: null,
+ hk: null,
+ tw: null,
+ musixmatchTranslation: null,
+ neteaseTranslation: null,
+ ...tempState,
+ language: defaultLanguage,
+ });
+ return;
}
- this.translateLyrics();
+ this.setState({ ...tempState });
}
- lyricsSource(mode) {
- const lyricsState = this.state[CONFIG.modes[mode]];
+ lyricsSource(lyricsState, mode) {
if (!lyricsState) return;
- this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState;
+
+ const lang = this.provideLanguageCode(this.state.currentLyrics);
+ const friendlyLanguage = lang && new Intl.DisplayNames(["en"], { type: "language" }).of(lang.split("-")[0])?.toLowerCase();
+
+ if (!this.displayMode) {
+ this.displayMode = CONFIG.visual[`translation-mode:${friendlyLanguage}`];
+ }
+
+ // get original Lyrics
+ const lyrics = lyricsState[CONFIG.modes[mode]];
+
+ if (CONFIG.visual.translate) {
+ this.state.currentLyrics = lyricsState[CONFIG.visual[`translation-mode:${friendlyLanguage}`]] ?? lyrics;
+ } else {
+ this.state.currentLyrics = lyricsState[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyrics;
+ }
+
+ // Convert Mode re-fresh
+ if (
+ this.translate !== CONFIG.visual.translate ||
+ this.languageOverride !== CONFIG.visual["translate:detect-language-override"] ||
+ this.displayMode !== CONFIG.visual[`translation-mode:${friendlyLanguage}`]
+ ) {
+ this.translate = CONFIG.visual.translate;
+ this.languageOverride = CONFIG.visual["translate:detect-language-override"];
+ this.displayMode = CONFIG.visual[`translation-mode:${friendlyLanguage}`];
+
+ if (CONFIG.visual.translate) {
+ const targetConvert = CONFIG.visual[`translation-mode:${friendlyLanguage}`];
+ const isCached = CACHE[lyricsState.uri]?.[targetConvert];
+
+ if (!isCached) {
+ this.translateLyrics(lang, lyrics, targetConvert).then((translated) => {
+ const res = { [targetConvert]: translated };
+ // Cache translated lyrics
+ CACHE[lyricsState.uri] = { ...CACHE[lyricsState.uri], ...res };
+ this.setState({ ...this.state, ...res });
+ });
+ }
+ } else {
+ const resetCache = { furigana: null, romaji: null, hiragana: null, katakana: null, hangul: null, romaja: null, cn: null, hk: null, tw: null };
+ CACHE[lyricsState.uri] = { ...CACHE[lyricsState.uri], ...resetCache };
+ }
+ }
}
provideLanguageCode(lyrics) {
if (!lyrics) return;
- if (CONFIG.visual["translate:detect-language-override"] !== "off") return CONFIG.visual["translate:detect-language-override"];
-
+ if (CONFIG.visual["translate:detect-language-override"] !== "off") {
+ return CONFIG.visual["translate:detect-language-override"];
+ }
+ if (this.state.language) {
+ return this.state.language;
+ }
return Utils.detectLanguage(lyrics);
}
- async translateLyrics(silent = true) {
- function showNotification(timeout) {
- if (silent) return;
- Spicetify.showNotification("Translating...", false, timeout);
+ async translateLyrics(language, lyrics, targetConvert) {
+ if (!language) return;
+
+ Spicetify.showNotification("Converting...", false, 1000);
+ if (!this.translator) {
+ this.translator = new Translator(language);
}
+ await this.translator.awaitFinished(language);
- const lyrics = this.state.currentLyrics;
- const language = this.provideLanguageCode(lyrics);
+ let result;
+ try {
+ if (language === "ja") {
+ // Japanese
+ const map = {
+ romaji: { target: "romaji", mode: "spaced" },
+ furigana: { target: "hiragana", mode: "furigana" },
+ hiragana: { target: "hiragana", mode: "normal" },
+ katakana: { target: "katakana", mode: "normal" },
+ };
- if (!CONFIG.visual.translate || !language || typeof lyrics?.[0].text !== "string") return;
+ result = await Promise.all(
+ lyrics.map(async (lyric) => await this.translator.romajifyText(lyric.text, map[targetConvert].target, map[targetConvert].mode))
+ );
+ } else if (language === "ko") {
+ // Korean
+ result = await Promise.all(lyrics.map(async (lyric) => await this.translator.convertToRomaja(lyric.text, "romaji")));
+ } else if (language === "zh-hans") {
+ // Chinese (Simplified)
+ const map = {
+ cn: { from: "cn", target: "cn" },
+ tw: { from: "cn", target: "tw" },
+ hk: { from: "cn", target: "hk" },
+ };
- if (!this.translator?.finished[language.slice(0, 2)]) {
- this.translator.injectExternals(language);
- this.translator.createTranslator(language);
- showNotification(500);
- setTimeout(this.translateLyrics.bind(this), 100, false);
- return;
- }
+ // prevent conversion between the same language.
+ if (targetConvert === "cn") {
+ Spicetify.showNotification("No conversion is needed", false, 1000);
+ return lyrics;
+ }
- // Seemingly long delay so it can be cleared later for accurate timing
- showNotification(10000);
- for (const params of [
- ["romaji", "spaced", "romaji"],
- ["hiragana", "furigana", "furigana"],
- ["hiragana", "normal", "hiragana"],
- ["katakana", "normal", "katakana"],
- ]) {
- if (language !== "ja") continue;
- Promise.all(lyrics.map((lyric) => this.translator.romajifyText(lyric.text, params[0], params[1]))).then((results) => {
- const result = results.join("\n");
- Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[2] });
- showNotification(200);
- lyricContainerUpdate?.();
- });
- }
+ result = await Promise.all(
+ lyrics.map(async (lyric) => await this.translator.convertChinese(lyric.text, map[targetConvert].from, map[targetConvert].target))
+ );
+ } else if (language === "zh-hant") {
+ // Chinese (Traditional)
+ const map = {
+ cn: { from: "t", target: "cn" },
+ hk: { from: "t", target: "hk" },
+ tw: { from: "t", target: "tw" },
+ };
- for (const params of [
- ["hangul", "hangul"],
- ["romaja", "romaja"],
- ]) {
- if (language !== "ko") continue;
- Promise.all(lyrics.map((lyric) => this.translator.convertToRomaja(lyric.text, params[1]))).then((results) => {
- const result = results.join("\n");
- Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[1] });
- showNotification(200);
- lyricContainerUpdate?.();
- });
- }
+ // prevent conversion between the same language.
+ if (targetConvert === "tw") {
+ Spicetify.showNotification("No conversion is needed", false, 1000);
+ return lyrics;
+ }
- for (const params of [
- ["cn", "hk"],
- ["cn", "tw"],
- ["t", "cn"],
- ["t", "hk"],
- ["t", "tw"],
- ]) {
- if (!language.includes("zh") || (language === "zh-hans" && params[0] === "t") || (language === "zh-hant" && params[0] === "cn")) continue;
- Promise.all(lyrics.map((lyric) => this.translator.convertChinese(lyric.text, params[0], params[1]))).then((results) => {
- const result = results.join("\n");
- Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[1] });
- showNotification(200);
- lyricContainerUpdate?.();
- });
+ result = await Promise.all(
+ lyrics.map(async (lyric) => await this.translator.convertChinese(lyric.text, map[targetConvert].from, map[targetConvert].target))
+ );
+ }
+
+ const res = Utils.processTranslatedLyrics(result, lyrics);
+ Spicetify.showNotification("Converting...", false, 0);
+ return res;
+ } catch (error) {
+ Spicetify.showNotification("Convert Error!", true);
+ console.error(error);
}
}
@@ -534,6 +593,14 @@ class LyricsContainer extends react.Component {
this.setState({ isCached: true });
}
+ deleteLocalLyrics(uri) {
+ const localLyrics = JSON.parse(localStorage.getItem(`${APP_NAME}:local-lyrics`)) || {};
+ delete localLyrics[uri];
+ localStorage.setItem(`${APP_NAME}:local-lyrics`, JSON.stringify(localLyrics));
+ console.log(localLyrics);
+ this.setState({ isCached: false });
+ }
+
lyricsSaved(uri) {
const localLyrics = JSON.parse(localStorage.getItem(`${APP_NAME}:local-lyrics`)) || {};
return !!localLyrics[uri];
@@ -581,6 +648,11 @@ class LyricsContainer extends react.Component {
reader.readAsText(file[0]);
event.target.value = "";
}
+ initMoustrap() {
+ if (!this.mousetrap && Spicetify.Mousetrap) {
+ this.mousetrap = new Spicetify.Mousetrap();
+ }
+ }
componentDidMount() {
this.onQueueChange = async ({ data: queue }) => {
@@ -595,7 +667,12 @@ class LyricsContainer extends react.Component {
// Debounce next track fetch
if (!nextInfo || nextInfo.uri === this.nextTrackUri) return;
this.nextTrackUri = nextInfo.uri;
- this.tryServices(nextInfo, this.state.explicitMode);
+ this.tryServices(nextInfo, this.state.explicitMode).then((resp) => {
+ if (resp.provider) {
+ // Cache lyrics
+ CACHE[resp.uri] = resp;
+ }
+ });
};
if (Spicetify.Player?.data?.item) {
@@ -608,10 +685,18 @@ class LyricsContainer extends react.Component {
Utils.addQueueListener(this.onQueueChange);
lyricContainerUpdate = () => {
+ this.reRenderLyricsPage = !this.reRenderLyricsPage;
this.updateVisualOnConfigChange();
this.forceUpdate();
};
+ reloadLyrics = () => {
+ CACHE = {};
+ this.updateVisualOnConfigChange();
+ this.forceUpdate();
+ this.fetchLyrics(Spicetify.Player.data.item, this.state.explicitMode, true);
+ };
+
this.viewPort =
document.querySelector(".Root__main-view .os-viewport") ?? document.querySelector(".Root__main-view .main-view-container__scroll-node");
@@ -686,45 +771,6 @@ class LyricsContainer extends react.Component {
this.mousetrap.bind(CONFIG.visual["fullscreen-key"], this.toggleFullscreen);
}
- componentDidUpdate() {
- // Apparently if any of these values are changed, the cached translation will not be updated, hence the need to retranslate
- if (
- this.translationProvider !== CONFIG.visual["translate:translated-lyrics-source"] ||
- this.languageOverride !== CONFIG.visual["translate:detect-language-override"] ||
- this.translate !== CONFIG.visual.translate
- ) {
- this.translationProvider = CONFIG.visual["translate:translated-lyrics-source"];
- this.languageOverride = CONFIG.visual["translate:detect-language-override"];
- this.translate = CONFIG.visual.translate;
-
- this.translateLyrics(false);
-
- return;
- }
-
- const language = this.provideLanguageCode(this.state.currentLyrics);
-
- let isTranslated = false;
-
- switch (language) {
- case "zh-hans":
- case "zh-hant": {
- isTranslated = !!(this.state.cn || this.state.hk || this.state.tw);
- break;
- }
- case "ja": {
- isTranslated = !!(this.state.romaji || this.state.furigana || this.state.hiragana || this.state.katakana);
- break;
- }
- case "ko": {
- isTranslated = !!(this.state.hangul || this.state.romaja);
- break;
- }
- }
-
- !isTranslated && this.translateLyrics();
- }
-
render() {
const fadLyricsContainer = document.getElementById("fad-lyrics-plus-container");
this.state.isFADMode = !!fadLyricsContainer;
@@ -769,16 +815,14 @@ class LyricsContainer extends react.Component {
let activeItem;
let showTranslationButton;
- let friendlyLanguage;
+ this.lyricsSource(this.state, mode);
+ const lang = this.provideLanguageCode(this.state.currentLyrics);
+ const friendlyLanguage = lang && new Intl.DisplayNames(["en"], { type: "language" }).of(lang.split("-")[0])?.toLowerCase();
const hasTranslation = this.state.neteaseTranslation !== null || this.state.musixmatchTranslation !== null;
if (mode !== -1) {
- this.lyricsSource(mode);
- const language = this.provideLanguageCode(this.state.currentLyrics);
- friendlyLanguage = language && new Intl.DisplayNames(["en"], { type: "language" }).of(language.split("-")[0])?.toLowerCase();
showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED);
- const translatedLyrics = this.state[CONFIG.visual[`translation-mode:${friendlyLanguage}`]];
if (mode === KARAOKE && this.state.karaoke) {
activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, {
@@ -787,20 +831,23 @@ class LyricsContainer extends react.Component {
lyrics: this.state.karaoke,
provider: this.state.provider,
copyright: this.state.copyright,
+ reRenderLyricsPage: this.reRenderLyricsPage,
});
} else if (mode === SYNCED && this.state.synced) {
activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, {
trackUri: this.state.uri,
- lyrics: CONFIG.visual.translate && translatedLyrics ? translatedLyrics : this.state.currentLyrics,
+ lyrics: this.state.currentLyrics,
provider: this.state.provider,
copyright: this.state.copyright,
+ reRenderLyricsPage: this.reRenderLyricsPage,
});
} else if (mode === UNSYNCED && this.state.unsynced) {
activeItem = react.createElement(UnsyncedLyricsPage, {
trackUri: this.state.uri,
- lyrics: CONFIG.visual.translate && translatedLyrics ? translatedLyrics : this.state.currentLyrics,
+ lyrics: this.state.currentLyrics,
provider: this.state.provider,
copyright: this.state.copyright,
+ reRenderLyricsPage: this.reRenderLyricsPage,
});
} else if (mode === GENIUS && this.state.genius) {
activeItem = react.createElement(GeniusPage, {
@@ -815,6 +862,7 @@ class LyricsContainer extends react.Component {
lyrics2: this.state.genius2,
versionIndex2: this.state.versionIndex2,
onVersionChange2: this.onVersionChange2.bind(this),
+ reRenderLyricsPage: this.reRenderLyricsPage,
});
}
}
@@ -882,8 +930,13 @@ class LyricsContainer extends react.Component {
return;
}
- this.saveLocalLyrics(this.currentTrackUri, { synced, unsynced, karaoke, genius });
- Spicetify.showNotification("Lyrics cached");
+ if (this.state.isCached) {
+ this.deleteLocalLyrics(this.currentTrackUri);
+ Spicetify.showNotification("Delete lyrics cache");
+ } else {
+ this.saveLocalLyrics(this.currentTrackUri, { synced, unsynced, karaoke, genius });
+ Spicetify.showNotification("Lyrics cached");
+ }
},
},
react.createElement("svg", {
@@ -940,6 +993,12 @@ class LyricsContainer extends react.Component {
switchCallback: (label) => {
const mode = CONFIG.modes.findIndex((a) => a === label);
if (mode !== this.state.mode) {
+ // If explicitMode is not set, moving the topBar will apply the default mode value for the selected song.
+ const info = this.infoFromTrack(Spicetify.Player.data.item);
+ if (info?.uri && CACHE[info?.uri]) {
+ CACHE[info.uri].mode = mode;
+ }
+
this.setState({ explicitMode: mode });
this.state.provider !== "local" && this.fetchLyrics(Spicetify.Player.data.item, mode);
}
diff --git a/CustomApps/lyrics-plus/style.css b/CustomApps/lyrics-plus/style.css
index c07fc38a4b..79325c80f8 100644
--- a/CustomApps/lyrics-plus/style.css
+++ b/CustomApps/lyrics-plus/style.css
@@ -704,6 +704,9 @@ div.lyrics-tabBar-headerItemLink {
.split .lyrics-versionSelector select {
width: 100%;
}
+.main-content-view {
+ height: 100%;
+}
@media (min-width: 1024px) {
.split .lyrics-lyricsContainer-LyricsLine {
diff --git a/CustomApps/reddit/TabBar.js b/CustomApps/reddit/TabBar.js
index bb146873be..7eaf636327 100644
--- a/CustomApps/reddit/TabBar.js
+++ b/CustomApps/reddit/TabBar.js
@@ -48,8 +48,9 @@ const TabBarMore = react.memo(({ items, switchTo }) => {
});
const TopBarContent = ({ links, activeLink, switchCallback }) => {
- const resizeHost =
- document.querySelector(".Root__main-view .os-resize-observer-host") ?? document.querySelector(".Root__main-view .os-size-observer");
+ const resizeHost = document.querySelector(
+ ".Root__main-view .os-resize-observer-host, .Root__main-view .os-size-observer, .Root__main-view .main-view-container__scroll-node"
+ );
const [windowSize, setWindowSize] = useState(resizeHost.clientWidth);
const resizeHandler = () => setWindowSize(resizeHost.clientWidth);
diff --git a/Extensions/popupLyrics.js b/Extensions/popupLyrics.js
index abde9ec914..bba91de09f 100644
--- a/Extensions/popupLyrics.js
+++ b/Extensions/popupLyrics.js
@@ -18,6 +18,7 @@ if (!navigator.serviceWorker) {
num = setInterval(() => postMessage("popup-lyric-update-ui"), 16.66);
} else if (event.data === "popup-lyric-stop-update") {
clearInterval(num);
+ postMessage("popup-lyric-update-ui");
num = null;
}
};
@@ -25,6 +26,8 @@ if (!navigator.serviceWorker) {
PopupLyrics();
}
+let CACHE = {};
+
function PopupLyrics() {
const { Player, CosmosAsync, LocalStorage, ContextMenu } = Spicetify;
@@ -40,6 +43,21 @@ function PopupLyrics() {
}
};
+ let workerIsRunning = null;
+ document.addEventListener("visibilitychange", (e) => {
+ if (e.target.hidden) {
+ if (!workerIsRunning) {
+ worker.postMessage("popup-lyric-request-update");
+ workerIsRunning = true;
+ }
+ } else {
+ if (workerIsRunning) {
+ worker.postMessage("popup-lyric-stop-update");
+ workerIsRunning = false;
+ }
+ }
+ });
+
const LyricUtils = {
normalize(s, emptySymbol = true) {
const result = s
@@ -324,7 +342,7 @@ function PopupLyrics() {
musixmatch: {
on: boolLocalStorage("popup-lyrics:services:musixmatch:on"),
call: LyricProviders.fetchMusixmatch,
- desc: `Fully compatible with Spotify. Requires a token that can be retrieved from the official Musixmatch app. Follow instructions on Spicetify Docs.`,
+ desc: "Fully compatible with Spotify. Requires a token that can be retrieved from the official Musixmatch app. If you have problems with retrieving lyrics, try refreshing the token by clicking Refresh Token
button.",
token: LocalStorage.get("popup-lyrics:services:musixmatch:token") || "2005218b74f939209bda92cb633c7380612e14cb7fe92dcd6a780f",
},
spotify: {
@@ -421,9 +439,11 @@ function PopupLyrics() {
let sharedData = {};
- Player.addEventListener("songchange", updateTrack);
+ Player.addEventListener("songchange", () => {
+ updateTrack();
+ });
- async function updateTrack() {
+ async function updateTrack(refresh = false) {
if (!lyricVideoIsOpen) {
return;
}
@@ -443,20 +463,25 @@ function PopupLyrics() {
uri: Player.data.item.uri,
};
- for (const name of userConfigs.servicesOrder) {
- const service = userConfigs.services[name];
- if (!service.on) continue;
- sharedData = { lyrics: [] };
+ if (CACHE?.[info.uri]?.lyrics?.length && !refresh) {
+ sharedData = CACHE[info.uri];
+ } else {
+ for (const name of userConfigs.servicesOrder) {
+ const service = userConfigs.services[name];
+ if (!service.on) continue;
+ sharedData = { lyrics: [] };
- try {
- const data = await service.call(info);
- console.log(data);
- sharedData = data;
- if (!sharedData.error) {
- return;
+ try {
+ const data = await service.call(info);
+ sharedData = data;
+ CACHE[info.uri] = sharedData;
+
+ if (!sharedData.error) {
+ return;
+ }
+ } catch (err) {
+ sharedData = { error: "No lyrics" };
}
- } catch (err) {
- sharedData = { error: "No lyrics" };
}
}
}
@@ -755,7 +780,6 @@ function PopupLyrics() {
ctx.restore();
}
- let workerIsRunning = null;
let timeout = null;
async function tick(options) {
@@ -791,17 +815,7 @@ function PopupLyrics() {
return;
}
- if (document.hidden) {
- if (!workerIsRunning) {
- worker.postMessage("popup-lyric-request-update");
- workerIsRunning = true;
- }
- } else {
- if (workerIsRunning) {
- worker.postMessage("popup-lyric-stop-update");
- workerIsRunning = false;
- }
-
+ if (!document.hidden) {
requestAnimationFrame(() => tick(options));
}
}
@@ -815,11 +829,20 @@ function PopupLyrics() {
function openConfig(event) {
event.preventDefault();
- if (!configContainer) {
+
+ // Reset on reopen
+ if (configContainer) {
+ resetTokenButton(configContainer);
+ } else {
configContainer = document.createElement("div");
configContainer.id = "popup-config-container";
const style = document.createElement("style");
style.innerHTML = `
+.setting-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
.setting-row::after {
content: "";
display: table;
@@ -831,13 +854,16 @@ function PopupLyrics() {
align-items: center;
}
.setting-row .col.description {
- float: left;
padding-right: 15px;
cursor: default;
+ width: 50%;
}
.setting-row .col.action {
- float: right;
- text-align: right;
+ justify-content: flex-end;
+ width: 50%;
+}
+.popup-config-col-margin {
+ margin-top: 10px;
}
button.switch {
align-items: center;
@@ -859,6 +885,27 @@ button.switch.small {
height: 22px;
padding: 6px;
}
+button.btn {
+ font-weight: 700;
+ display: block;
+ background-color: rgba(var(--spice-rgb-shadow), .7);
+ border-radius: 500px;
+ transition-duration: 33ms;
+ transition-property: background-color, border-color, color, box-shadow, filter, transform;
+ padding-inline: 15px;
+ border: 1px solid #727272;
+ color: var(--spice-text);
+ min-block-size: 32px;
+ cursor: pointer;
+}
+button.btn:hover {
+ transform: scale(1.04);
+ border-color: var(--spice-text);
+}
+button.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
#popup-config-container select {
color: var(--spice-text);
background: rgba(var(--spice-rgb-shadow), .7);
@@ -867,7 +914,6 @@ button.switch.small {
}
#popup-config-container input {
width: 100%;
- margin-top: 10px;
padding: 0 5px;
height: 32px;
border: 0;
@@ -945,6 +991,13 @@ button.switch.small {
userConfigs.delay = Number(state);
LocalStorage.set("popup-lyrics:delay", state);
});
+ const clearCache = descriptiveElement(
+ createButton("Clear Memory Cache", "Clear Memory Cache", () => {
+ CACHE = {};
+ updateTrack();
+ }),
+ "Loaded lyrics are cached in memory for faster reloading. Press this button to clear the cached lyrics from memory without restarting Spotify."
+ );
const serviceHeader = document.createElement("h2");
serviceHeader.innerText = "Services";
@@ -975,7 +1028,7 @@ button.switch.small {
const id = el.dataset.id;
userConfigs.services[id].on = state;
LocalStorage.set(`popup-lyrics:services:${id}:on`, state);
- updateTrack();
+ updateTrack(true);
}
function posCallback(el, dir) {
@@ -990,23 +1043,28 @@ button.switch.small {
LocalStorage.set("popup-lyrics:services-order", JSON.stringify(userConfigs.servicesOrder));
stackServiceElements();
- updateTrack();
- }
-
- function tokenChangeCallback(el, inputEl) {
- const newVal = inputEl.value;
- const id = el.dataset.id;
- userConfigs.services[id].token = newVal;
- LocalStorage.set(`popup-lyrics:services:${id}:token`, newVal);
- updateTrack();
+ updateTrack(true);
}
for (const name of userConfigs.servicesOrder) {
- userConfigs.services[name].element = createServiceOption(name, userConfigs.services[name], switchCallback, posCallback, tokenChangeCallback);
+ userConfigs.services[name].element = createServiceOption(name, userConfigs.services[name], switchCallback, posCallback);
}
stackServiceElements();
- configContainer.append(style, optionHeader, smooth, center, cover, blurSize, fontSize, ratio, delay, serviceHeader, serviceContainer);
+ configContainer.append(
+ style,
+ optionHeader,
+ smooth,
+ center,
+ cover,
+ blurSize,
+ fontSize,
+ ratio,
+ delay,
+ clearCache,
+ serviceHeader,
+ serviceContainer
+ );
}
Spicetify.PopupModal.display({
title: "Popup Lyrics",
@@ -1084,8 +1142,125 @@ button.switch.small {
return container;
}
+ // if name is null, the element can be used without a description.
+ function createButton(name, defaultValue, callback) {
+ let container;
+
+ if (name) {
+ container = document.createElement("div");
+ container.innerHTML = `
+
`;
+
+ const button = container.querySelector("#popup-lyrics-clickbutton");
+ button.onclick = () => {
+ callback();
+ };
+ } else {
+ container = document.createElement("button");
+ container.innerHTML = defaultValue;
+ container.className = "btn ";
+
+ container.onclick = () => {
+ callback();
+ };
+ }
+
+ return container;
+ }
+ // if name is null, the element can be used without a description.
+ function createTextfield(name, defaultValue, placeholder, callback) {
+ let container;
+
+ if (name) {
+ container = document.createElement("div");
+ container.className = "setting-column";
+ container.innerHTML = `
+
+ `;
+
+ const textfield = container.querySelector("#popup-lyrics-textfield");
+ textfield.onchange = () => {
+ callback();
+ };
+ } else {
+ container = document.createElement("input");
+ container.placeholder = placeholder;
+ container.value = defaultValue;
+
+ container.onchange = (e) => {
+ callback(e.target.value);
+ };
+ }
+
+ return container;
+ }
+ function descriptiveElement(element, description) {
+ const desc = document.createElement("span");
+ desc.innerHTML = description;
+ element.append(desc);
+ return element;
+ }
+
+ function resetTokenButton(container) {
+ const button = container.querySelector("#popup-lyrics-refresh-token");
+ if (button) {
+ button.innerHTML = "Refresh token";
+ button.disabled = false;
+ }
+ }
+
+ function musixmatchTokenElements(defaultVal, id) {
+ const button = createButton(null, "Refresh token", clickRefresh);
+ button.className += "popup-config-col-margin";
+ button.id = "popup-lyrics-refresh-token";
+ const textfield = createTextfield(null, defaultVal.token, `Place your ${id} token here`, changeTokenfield);
+ textfield.className += "popup-config-col-margin";
+
+ function clickRefresh() {
+ button.innerHTML = "Refreshing token...";
+ button.disabled = true;
+
+ 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) {
+ button.innerHTML = "Token refreshed";
+ textfield.value = response.body.user_token;
+ textfield.dispatchEvent(new Event("change"));
+ } else if (response.header.status_code === 401) {
+ button.innerHTML = "Too many attempts";
+ } else {
+ button.innerHTML = "Failed to refresh token";
+ console.error("Failed to refresh token", response);
+ }
+ })
+ .catch((error) => {
+ button.innerHTML = "Failed to refresh token";
+ console.error("Failed to refresh token", error);
+ });
+ }
+
+ function changeTokenfield(value) {
+ userConfigs.services.musixmatch.token = value;
+ LocalStorage.set("popup-lyrics:services:musixmatch:token", value);
+ updateTrack(true);
+ }
+
+ const container = document.createElement("div");
+ container.append(button);
+ container.append(textfield);
+ return container;
+ }
- function createServiceOption(id, defaultVal, switchCallback, posCallback, tokenCallback) {
+ function createServiceOption(id, defaultVal, switchCallback, posCallback) {
const name = id.replace(/^./, (c) => c.toUpperCase());
const container = document.createElement("div");
@@ -1113,12 +1288,8 @@ button.switch.small {
${defaultVal.desc}`;
- if (defaultVal.token !== undefined) {
- const input = document.createElement("input");
- input.placeholder = `Place your ${id} token here`;
- input.value = defaultVal.token;
- input.onchange = () => tokenCallback(container, input);
- container.append(input);
+ if (id === "musixmatch") {
+ container.append(musixmatchTokenElements(defaultVal));
}
const [up, down, slider] = container.querySelectorAll("button");
diff --git a/README.md b/README.md
index 1ad36819b2..b0a065af1d 100644
--- a/README.md
+++ b/README.md
@@ -27,3 +27,7 @@ Supports Windows, MacOS and Linux.
- [Installation](https://spicetify.app/docs/getting-started)
- [Basic Usage](https://spicetify.app/docs/getting-started#basic-usage)
- [FAQ](https://spicetify.app/docs/faq)
+
+### Code Signing Policy
+
+Free code signing provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org/).
diff --git a/css-map.json b/css-map.json
index ffc58ba9a6..16a20358a6 100644
--- a/css-map.json
+++ b/css-map.json
@@ -150,6 +150,7 @@
"vapgYYF2HMEeLJuOWGq5": "lyrics-lyricsContent-isInteractive",
"NiCdLCpp3o2z6nBrayOn": "lyrics-lyricsContent-lyric",
"nw6rbs8R08fpPn7RWW2w": "lyrics-lyricsContent-lyric",
+ "BJ1zQ_ReY3QPaS7SW46s": "lyrics-lyricsContent-lyric",
"kGR_hu4tdj9PnUlSPaRL": "lyrics-lyricsContent-provider",
"LomBcMvfM8AEmZGquAdj": "lyrics-lyricsContent-provider",
"A3ohAQNHsDIMv2EM3Ytp": "lyrics-lyricsContent-text",
@@ -158,6 +159,7 @@
"SruqsAzX8rUtY2isUZDF": "lyrics-lyricsContent-unsynced",
"E4q8ogfdWtye7YgotBlN": "main-actionBar-ActionBar",
"eSg4ntPU2KQLfpLGXAww": "main-actionBar-ActionBarRow",
+ "K06ol8ltPT_atXE_JjUP": "main-actionBar-exploreButton",
"CoLO4pdSl8LGWyVZA00t": "main-actionBarBackground-background",
"PkOz5g82CaoKk1J3GX0e": "main-actionBarBackground-background",
"GTAFfOA_w5vh_bDaGJAG": "main-actionButtons",
@@ -271,6 +273,7 @@
"X05XDhpQJ7THPHfgbUk1": "main-confirmDialog-buttonContainer",
"RVgHI2ejYct8LjT1AO7m": "main-confirmDialog-container",
"m0yIuS1Q6XRA5R4PNEhl": "main-confirmDialog-overlay",
+ "KL8t9WB65UfUEPuTFAhO": "main-content-view",
"gQoa8JTSpjSmYyABcag2": "main-connectBar-connectBar",
"T3hkVxXuSbCYOD2GIeQd": "main-connectBar-connected",
"GcHojieewpdN1c8vbtwk": "main-connectBar-connecting",
diff --git a/globals.d.ts b/globals.d.ts
index a7e6f6beab..b32889e6a0 100644
--- a/globals.d.ts
+++ b/globals.d.ts
@@ -540,6 +540,7 @@ declare namespace Spicetify {
* @param uri Any type of URI that has artwork (playlist, track, album, artist, show, ...)
*/
function colorExtractor(uri: string): Promise<{
+ DARK_VIBRANT: string;
DESATURATED: string;
LIGHT_VIBRANT: string;
PROMINENT: string;
diff --git a/go.mod b/go.mod
index 4be4d5217c..a8ee299b93 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,27 @@
module github.com/spicetify/cli
-go 1.21
+go 1.24.2
require (
github.com/go-ini/ini v1.67.0
- github.com/mattn/go-colorable v0.1.13
- golang.org/x/net v0.33.0
+ github.com/mattn/go-colorable v0.1.14
+ github.com/pterm/pterm v0.12.81
+ golang.org/x/net v0.41.0
+ golang.org/x/sys v0.33.0
)
require (
- github.com/mattn/go-isatty v0.0.16 // indirect
- github.com/stretchr/testify v1.7.1 // indirect
- golang.org/x/sys v0.28.0 // indirect
+ atomicgo.dev/cursor v0.2.0 // indirect
+ atomicgo.dev/keyboard v0.2.9 // indirect
+ atomicgo.dev/schedule v0.1.0 // indirect
+ github.com/containerd/console v1.0.5 // indirect
+ github.com/gookit/color v1.5.4 // indirect
+ github.com/lithammer/fuzzysearch v1.1.8 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/stretchr/testify v1.9.0 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/term v0.32.0 // indirect
+ golang.org/x/text v0.26.0 // indirect
)
diff --git a/go.sum b/go.sum
index 54429604c0..9eca6d1efe 100644
--- a/go.sum
+++ b/go.sum
@@ -1,21 +1,129 @@
-github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg=
+atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ=
+atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
+atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
+atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
+atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
+atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
+atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
+github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
+github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
+github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
+github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k=
+github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI=
+github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c=
+github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
+github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
+github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
+github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
+github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
+github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
+github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
+github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
+github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
+github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
+github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
+github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
+github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg=
+github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE=
+github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU=
+github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
+github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
+github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
+github.com/pterm/pterm v0.12.81 h1:ju+j5I2++FO1jBKMmscgh5h5DPFDFMB7epEjSoKehKA=
+github.com/pterm/pterm v0.12.81/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/install.ps1 b/install.ps1
index cdd92c9dcc..d03c8e0e11 100644
--- a/install.ps1
+++ b/install.ps1
@@ -78,7 +78,7 @@ function Get-Spicetify {
$targetVersion = $v
}
else {
- Write-Warning -Message "You have spicefied an invalid spicetify version: $v `nThe version must be in the following format: 1.2.3"
+ Write-Warning -Message "You have specified an invalid spicetify version: $v `nThe version must be in the following format: 1.2.3"
Pause
exit
}
diff --git a/jsHelper/expFeatures.js b/jsHelper/expFeatures.js
index f8eff46ba3..2b5ea279e9 100644
--- a/jsHelper/expFeatures.js
+++ b/jsHelper/expFeatures.js
@@ -166,7 +166,8 @@
}
isFallback = false;
notice.remove();
- remoteConfiguration = Spicetify?.RemoteConfigResolver?.remoteConfiguration ?? (await Spicetify.Platform?.RemoteConfigDebugAPI.getProperties());
+ remoteConfiguration =
+ Spicetify?.RemoteConfigResolver?.value.remoteConfiguration ?? (await Spicetify.Platform?.RemoteConfigDebugAPI.getProperties());
})();
for (const key of Object.keys(overrideList)) {
diff --git a/jsHelper/homeConfig.js b/jsHelper/homeConfig.js
index 59fc1db80d..feebf9f903 100644
--- a/jsHelper/homeConfig.js
+++ b/jsHelper/homeConfig.js
@@ -92,7 +92,7 @@ SpicetifyHomeConfig = {};
const main = document.querySelector(".main-home-content");
elem = [...main.querySelectorAll("section")];
for (const [index, item] of elem.entries()) {
- item.dataset.uri = list[index].uri ?? list[index].item.uri;
+ item.dataset.uri = list[index]?.uri ?? list[index].item?.uri;
}
function appendItems() {
diff --git a/jsHelper/spicetifyWrapper.js b/jsHelper/spicetifyWrapper.js
index 988ff2c874..428ec6a8ca 100644
--- a/jsHelper/spicetifyWrapper.js
+++ b/jsHelper/spicetifyWrapper.js
@@ -344,6 +344,60 @@ window.Spicetify = {
}
})();
+// Based on https://blog.aziz.tn/2025/01/spotify-fix-lagging-issue-on-scrolling.html
+function applyScrollingFix() {
+ if (!Spicetify.Platform?.version) {
+ setTimeout(applyScrollingFix, 50);
+ return;
+ }
+
+ // Run only for 1.2.56 and lower
+ const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i));
+ if (version[1] >= 2 && version[2] >= 57) return;
+
+ const scrollableElements = Array.from(document.querySelectorAll("*")).filter((el) => {
+ if (
+ el.id === "context-menu" ||
+ el.closest("#context-menu") ||
+ el.getAttribute("role") === "dialog" ||
+ el.classList.contains("popup") ||
+ el.getAttribute("aria-haspopup") === "true"
+ )
+ return false;
+
+ const style = window.getComputedStyle(el);
+ return style.overflow === "auto" || style.overflow === "scroll" || style.overflowY === "auto" || style.overflowY === "scroll";
+ });
+
+ for (const el of scrollableElements) {
+ if (!el.hasAttribute("data-scroll-optimized")) {
+ el.style.willChange = "transform";
+ el.style.transform = "translate3d(0, 0, 0)";
+ el.setAttribute("data-scroll-optimized", "true");
+ }
+ }
+}
+
+const observer = new MutationObserver(applyScrollingFix);
+
+observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ attributes: false,
+});
+
+const originalPushState = history.pushState;
+history.pushState = function (...args) {
+ originalPushState.apply(this, args);
+ setTimeout(applyScrollingFix, 100);
+};
+
+window.addEventListener("popstate", () => {
+ setTimeout(applyScrollingFix, 100);
+});
+
+applyScrollingFix();
+
(async function addProxyCosmos() {
if (!Spicetify.Player.origin?._cosmos && !Spicetify.Platform?.Registry) {
setTimeout(addProxyCosmos, 50);
@@ -368,7 +422,7 @@ window.Spicetify = {
if (typeof internalFetch !== "function" || !allowedMethodsSet.has(prop)) return internalFetch;
const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i));
- if (version[0] === 1 && version[1] === 2 && version[2] < 31) return internalFetch;
+ if (version[1] >= 2 && version[2] < 31) return internalFetch;
return async function (url, body) {
const urlObj = new URL(url);
@@ -515,13 +569,19 @@ window.Spicetify = {
};
const reactComponentsUI = exposeReactComponentsUI({ modules, functionModules, exportedForwardRefs });
- const knownMenuTypes = ["album", "show", "artist", "track"];
+ const knownMenuTypes = ["album", "show", "artist", "track", "playlist"];
const menus = modules
- .map((m) => m?.type?.toString().match(/value:"[\w-]+"/g) && [m, ...m.type.toString().match(/value:"[\w-]+"/g)])
+ .map((m) => {
+ const valueMatch = m?.type?.toString().match(/value:"([\w-]+)"/);
+ if (valueMatch) return [m, valueMatch[1]];
+ const typeMatch = m?.type?.toString().match(/type:[\w$]+\.[\w$]+\.([A-Z_]+)/);
+ if (typeMatch) return [m, typeMatch[1].toLowerCase()];
+ return null;
+ })
.filter(Boolean)
.filter((m) => m[1] !== 'value:"row"')
.map(([module, type]) => {
- type = type.match(/value:"([\w-]+)"/)[1];
+ type = type.match(/value:"([\w-]+)"/)?.[1] ?? type;
if (!knownMenuTypes.includes(type)) return;
if (type === "show") type = "podcast-show";
@@ -630,7 +690,6 @@ window.Spicetify = {
Routes: functionModules.find((m) => m.toString().match(/\([\w$]+\)\{let\{children:[\w$]+,location:[\w$]+\}=[\w$]+/)),
Route: functionModules.find((m) => m.toString().match(/^function [\w$]+\([\w$]+\)\{\(0,[\w$]+\.[\w$]+\)\(\!1\)\}$/)),
StoreProvider: functionModules.find((m) => m.toString().includes("notifyNestedSubs") && m.toString().includes("serverState")),
- Navigation: exportedMemoFRefs.find((m) => m.type.render.toString().includes("navigationalRoot")),
ScrollableContainer: functionModules.find((m) => m.toString().includes("scrollLeft") && m.toString().includes("showButtons")),
IconComponent: reactComponentsUI.Icon,
...Object.fromEntries(menus),
@@ -672,11 +731,14 @@ window.Spicetify = {
});
if (!Spicetify.ContextMenuV2._context) Spicetify.ContextMenuV2._context = Spicetify.React.createContext({});
+ if (!Spicetify.ReactComponent.Navigation)
+ Spicetify.ReactComponent.Navigation = exportedMemoFRefs.find((m) => m.type.render.toString().includes("navigationalRoot"));
(function waitForChunks() {
const listOfComponents = [
"ScrollableContainer",
"Slider",
+ "Dropdown",
"Toggle",
"Cards.Artist",
"Cards.Audiobook",
@@ -727,6 +789,18 @@ window.Spicetify = {
);
Object.assign(Spicetify.ReactComponent.Cards, Object.fromEntries(cards));
+ // chunks
+ const dropdownChunk = chunks.find(([, value]) => value.toString().includes("dropDown") && value.toString().includes("isSafari"));
+ if (dropdownChunk) {
+ Spicetify.ReactComponent.Dropdown =
+ Object.values(require(dropdownChunk[0]))?.[0]?.render ?? Object.values(require(dropdownChunk[0])).find((m) => typeof m === "function");
+ }
+
+ const toggleChunk = chunks.find(([, value]) => value.toString().includes("onSelected") && value.toString().includes('type:"checkbox"'));
+ if (toggleChunk && !Spicetify.ReactComponent.Toggle) {
+ Spicetify.ReactComponent.Toggle = Object.values(require(toggleChunk[0]))[0].render;
+ }
+
if (!listOfComponents.every((component) => Spicetify.ReactComponent[component] !== undefined)) {
setTimeout(waitForChunks, 100);
return;
@@ -827,17 +901,12 @@ window.Spicetify = {
const playlistMenuChunk = chunks.find(
([, value]) => value.toString().includes('value:"playlist"') && value.toString().includes("canView") && value.toString().includes("permissions")
);
- if (playlistMenuChunk) {
+ if (playlistMenuChunk && !Spicetify.ReactComponent?.PlaylistMenu) {
Spicetify.ReactComponent.PlaylistMenu = Object.values(require(playlistMenuChunk[0])).find(
(m) => typeof m === "function" || typeof m === "object"
);
}
- const dropdownChunk = chunks.find(([, value]) => value.toString().includes("dropDown") && value.toString().includes("isSafari"));
- if (dropdownChunk) {
- Spicetify.ReactComponent.Dropdown = Object.values(require(dropdownChunk[0])).find((m) => typeof m === "function");
- }
-
const infiniteQueryChunk = chunks.find(
([_, value]) => value.toString().includes("fetchPreviousPage") && value.toString().includes("getOptimisticResult")
);
@@ -1151,6 +1220,7 @@ Spicetify._getStyledClassName = (args, component) => {
"$buttonSize",
"$position",
"$iconSize",
+ "$lineClamp",
];
const customKeys = ["blocksize"];
const customExactKeys = ["$padding", "$paddingBottom", "paddingBottom", "padding"];
@@ -1675,7 +1745,9 @@ Spicetify.ContextMenuV2 = (() => {
}
class ItemSubMenu {
- static itemsToComponents = (items) => items.map((item) => item._element);
+ static itemsToComponents = (items, props, trigger, target) => {
+ return items.filter((item) => (item.shouldAdd || (() => true))?.(props, trigger, target)).map((item) => item._element);
+ };
constructor({ text, disabled = false, leadingIcon, divider, items, shouldAdd = () => true }) {
this.shouldAdd = shouldAdd;
@@ -1683,6 +1755,7 @@ Spicetify.ContextMenuV2 = (() => {
this._text = text;
this._disabled = disabled;
this._leadingIcon = leadingIcon;
+ this._divider = divider;
this._items = items;
this._element = Spicetify.ReactJSX.jsx(() => {
const [_text, setText] = Spicetify.React.useState(this._text);
@@ -1706,6 +1779,9 @@ Spicetify.ContextMenuV2 = (() => {
};
});
+ const context = Spicetify.React.useContext(Spicetify.ContextMenuV2._context) ?? {};
+ const { props, trigger, target } = context;
+
return Spicetify.React.createElement(Spicetify.ReactComponent.MenuSubMenuItem, {
displayText: _text,
divider: _divider,
@@ -1715,7 +1791,7 @@ Spicetify.ContextMenuV2 = (() => {
onClick: () => undefined,
disabled: _disabled,
leadingIcon: _leadingIcon && createIconComponent(_leadingIcon),
- children: ItemSubMenu.itemsToComponents(_items),
+ children: ItemSubMenu.itemsToComponents(_items, props, trigger, target),
});
}, {});
}
@@ -2202,7 +2278,7 @@ Spicetify.Topbar = (() => {
function waitForTopbarMounted() {
const globalHistoryButtons = document.querySelector(".main-globalNav-historyButtons");
leftGeneratedClassName = document.querySelector(
- ".main-topBar-historyButtons .main-topBar-button, .main-globalNav-historyButtons .main-globalNav-icon"
+ ".main-topBar-historyButtons .main-topBar-button, .main-globalNav-historyButtons .main-globalNav-icon, .main-globalNav-historyButtons [data-encore-id='buttonTertiary']"
)?.className;
rightGeneratedClassName = document.querySelector(
".main-topBar-container .main-topBar-buddyFeed, .main-actionButtons .main-topBar-buddyFeed, .main-actionButtons .main-globalNav-buddyFeed"
diff --git a/spicetify.go b/spicetify.go
index 59de163f32..a93fc0ea05 100644
--- a/spicetify.go
+++ b/spicetify.go
@@ -17,6 +17,7 @@ import (
"github.com/spicetify/cli/src/cmd"
spotifystatus "github.com/spicetify/cli/src/status/spotify"
"github.com/spicetify/cli/src/utils"
+ "github.com/spicetify/cli/src/utils/isAdmin"
)
var (
@@ -24,14 +25,15 @@ var (
)
var (
- flags = []string{}
- commands = []string{}
- quiet = false
- extensionFocus = false
- appFocus = false
- styleFocus = false
- noRestart = false
- liveRefresh = false
+ flags = []string{}
+ commands = []string{}
+ quiet = false
+ extensionFocus = false
+ appFocus = false
+ styleFocus = false
+ noRestart = false
+ liveRefresh = false
+ bypassAdminCheck = false
)
func init() {
@@ -66,6 +68,8 @@ func init() {
for _, v := range flags {
switch v {
+ case "--bypass-admin":
+ bypassAdminCheck = true
case "-c", "--config":
fmt.Println(cmd.GetConfigPath())
os.Exit(0)
@@ -110,7 +114,24 @@ func init() {
os.Stdout = nil
}
+ if isAdmin.Check(bypassAdminCheck) {
+ utils.PrintError("Spicetify should not be run with administrator/root privileges")
+ utils.PrintError("Running as admin can cause Spotify to show a black/blank window after applying spicetify")
+ utils.PrintError("This happens because Spotify (running as a normal user) can't access files modified with admin privileges")
+ utils.PrintInfo("If you understand the risks and need to continue anyway, you can use the '--bypass-admin' flag.")
+ utils.PrintInfo("Spicetify is now exiting...")
+ os.Exit(1)
+ }
+
+ for i, flag := range flags {
+ if flag == "--bypass-admin" {
+ flags = append(flags[:i], flags[i+1:]...)
+ break
+ }
+ }
+
utils.MigrateConfigFolder()
+ utils.MigrateFolders()
cmd.InitConfig(quiet)
if len(commands) < 1 {
diff --git a/src/apply/apply.go b/src/apply/apply.go
index 945f67c6bc..19b87864e7 100644
--- a/src/apply/apply.go
+++ b/src/apply/apply.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strconv"
"strings"
"github.com/spicetify/cli/src/utils"
@@ -26,18 +27,19 @@ type Flag struct {
// AdditionalOptions .
func AdditionalOptions(appsFolderPath string, flags Flag) {
+ jsModifiers := []func(path string, flags Flag){
+ insertExpFeatures,
+ insertSidebarConfig,
+ insertHomeConfig,
+ }
filesToModified := map[string][]func(path string, flags Flag){
filepath.Join(appsFolderPath, "xpui", "index.html"): {
htmlMod,
},
- filepath.Join(appsFolderPath, "xpui", "xpui.js"): {
+ filepath.Join(appsFolderPath, "xpui", "xpui.js"): jsModifiers,
+ filepath.Join(appsFolderPath, "xpui", "xpui-modules.js"): jsModifiers,
+ filepath.Join(appsFolderPath, "xpui", "xpui-snapshot.js"): {
insertCustomApp,
- insertExpFeatures,
- insertSidebarConfig,
- insertHomeConfig,
- },
- filepath.Join(appsFolderPath, "xpui", "vendor~xpui.js"): {
- insertExpFeatures,
},
filepath.Join(appsFolderPath, "xpui", "home-v2.js"): {
insertHomeConfig,
@@ -47,6 +49,25 @@ func AdditionalOptions(appsFolderPath string, flags Flag) {
},
}
+ verParts := strings.Split(flags.SpotifyVer, ".")
+ spotifyMajor, spotifyMinor, spotifyPatch := 0, 0, 0
+ if len(verParts) > 0 {
+ spotifyMajor, _ = strconv.Atoi(verParts[0])
+ }
+ if len(verParts) > 1 {
+ spotifyMinor, _ = strconv.Atoi(verParts[1])
+ }
+ if len(verParts) > 2 {
+ spotifyPatch, _ = strconv.Atoi(verParts[2])
+ }
+
+ filesToModified[filepath.Join(appsFolderPath, "xpui", "xpui.js")] = append(filesToModified[filepath.Join(appsFolderPath, "xpui", "xpui.js")], insertCustomApp)
+ if spotifyMajor >= 1 && spotifyMinor >= 2 && spotifyPatch >= 57 {
+ filesToModified[filepath.Join(appsFolderPath, "xpui", "xpui.js")] = append(filesToModified[filepath.Join(appsFolderPath, "xpui", "xpui.js")], insertExpFeatures)
+ } else {
+ filesToModified[filepath.Join(appsFolderPath, "xpui", "vendor~xpui.js")] = []func(string, Flag){insertExpFeatures}
+ }
+
for file, calls := range filesToModified {
if _, err := os.Stat(file); os.IsNotExist(err) {
continue
@@ -171,6 +192,12 @@ func htmlMod(htmlPath string, flags Flag) {
}
utils.ModifyFile(htmlPath, func(content string) string {
+ utils.Replace(
+ &content,
+ ``,
+ func(submatches ...string) string {
+ return ``
+ })
utils.Replace(
&content,
`<\!-- spicetify helpers -->`,
@@ -361,10 +388,10 @@ func insertHomeConfig(jsPath string, flags Flag) {
return fmt.Sprintf("%sSpicetifyHomeConfig.arrange(%s)%s", submatches[1], submatches[2], submatches[3])
})
- // >= 1.2.45
+ // >= 1.2.40
utils.ReplaceOnce(
&content,
- `(&&"HomeShortsSectionData".*\],)([a-zA-Z])(\}\)\()`,
+ `(&&"HomeShortsSectionData".*?[\],}])([a-zA-Z])(\}\)?\()`,
func(submatches ...string) string {
return fmt.Sprintf("%sSpicetifyHomeConfig.arrange(%s)%s", submatches[1], submatches[2], submatches[3])
})
diff --git a/src/cmd/apply.go b/src/cmd/apply.go
index 89d463b844..e2a3e0f415 100644
--- a/src/cmd/apply.go
+++ b/src/cmd/apply.go
@@ -177,9 +177,9 @@ func RefreshExtensions(list ...string) {
if len(list) > 0 {
pushExtensions("", list...)
- utils.PrintSuccess(utils.PrependTime("All extensions are updated."))
+ utils.PrintSuccess("All extensions are updated")
} else {
- utils.PrintError("No extension to update.")
+ utils.PrintError("No extension to update")
}
}
@@ -192,9 +192,9 @@ func CheckStates() {
if backStat.IsEmpty() {
if spotStat.IsBackupable() {
- utils.PrintError(`You haven't backed up. Run "spicetify backup apply".`)
+ utils.PrintError(`You haven't backed up. Run "spicetify backup apply"`)
} else {
- utils.PrintError(`You haven't backed up and Spotify cannot be backed up at this state. Please re-install Spotify then run "spicetify backup apply".`)
+ utils.PrintError(`You haven't backed up and Spotify cannot be backed up at this state. Please re-install Spotify then run "spicetify backup apply"`)
}
os.Exit(1)
@@ -202,13 +202,13 @@ func CheckStates() {
utils.PrintWarning("Spotify version and backup version are mismatched.")
if spotStat.IsMixed() {
- utils.PrintInfo(`Spotify client possibly just had an new update.`)
- utils.PrintInfo(`Please run "spicetify backup apply".`)
+ utils.PrintInfo(`Spotify client possibly just had a new update`)
+ utils.PrintInfo(`Please run "spicetify backup apply"`)
} else if spotStat.IsStock() {
- utils.PrintInfo(`Spotify client is in stock state.`)
- utils.PrintInfo(`Please run "spicetify backup apply".`)
+ utils.PrintInfo(`Spotify client is in stock state`)
+ utils.PrintInfo(`Please run "spicetify backup apply"`)
} else {
- utils.PrintInfo(`Spotify cannot be backed up at this state. Please re-install Spotify then run "spicetify backup apply".`)
+ utils.PrintInfo(`Spotify cannot be backed up at this state. Please re-install Spotify then run "spicetify backup apply"`)
}
os.Exit(1)
@@ -243,7 +243,7 @@ func pushExtensions(destExt string, list ...string) {
}
extPath, err = utils.GetExtensionPath(extName)
if err != nil {
- utils.PrintError(`Extension "` + extName + `" not found.`)
+ utils.PrintError(`Extension "` + extName + `" not found`)
continue
}
}
@@ -281,7 +281,7 @@ func RefreshApps(list ...string) {
customAppPath, err := utils.GetCustomAppPath(app)
if err != nil {
- utils.PrintError(`Custom app "` + app + `" not found.`)
+ utils.PrintError(`Custom app "` + app + `" not found`)
continue
}
diff --git a/src/cmd/backup.go b/src/cmd/backup.go
index 4c20e921c7..37abc2a037 100644
--- a/src/cmd/backup.go
+++ b/src/cmd/backup.go
@@ -16,9 +16,9 @@ import (
// extracted apps' assets
func Backup(spicetifyVersion string) {
if isAppX {
- utils.PrintInfo(`You are using Spotify Windows Store version, which is only partly supported.
-Stop using Spicetify with Windows Store version unless you absolutely CANNOT install normal Spotify from installer.
-Modded Spotify cannot be launched using original Shortcut/Start menu tile. To correctly launch Spotify with modification, please make a desktop shortcut that execute "spicetify auto". After that, you can change its icon, pin to start menu or put in startup folder.`)
+ utils.PrintInfo(`You are using Spotify Windows Store version, which is only partly supported
+Stop using Spicetify with Windows Store version unless you absolutely CANNOT install normal Spotify from installer
+Modded Spotify cannot be launched using original Shortcut/Start menu tile. To correctly launch Spotify with modification, please make a desktop shortcut that execute "spicetify auto". After that, you can change its icon, pin to start menu or put in startup folder`)
if !ReadAnswer("Continue backing up anyway? [y/N]: ", false, true) {
os.Exit(1)
}
@@ -26,7 +26,7 @@ Modded Spotify cannot be launched using original Shortcut/Start menu tile. To co
backupVersion := backupSection.Key("version").MustString("")
backStat := backupstatus.Get(prefsPath, backupFolder, backupVersion)
if !backStat.IsEmpty() {
- utils.PrintInfo("There is available backup.")
+ utils.PrintInfo("There is available backup")
utils.PrintInfo("Clear current backup:")
spotStat := spotifystatus.Get(appPath)
@@ -34,8 +34,8 @@ Modded Spotify cannot be launched using original Shortcut/Start menu tile. To co
clearBackup()
} else {
- utils.PrintWarning(`After clearing backup, Spotify cannot be backed up again.`)
- utils.PrintInfo(`Please restore first then backup, run "spicetify restore backup" or re-install Spotify then run "spicetify backup".`)
+ utils.PrintWarning(`After clearing backup, Spotify cannot be backed up again`)
+ utils.PrintInfo(`Please restore first then backup, run "spicetify restore backup" or re-install Spotify then run "spicetify backup"`)
os.Exit(1)
}
}
@@ -55,7 +55,7 @@ Modded Spotify cannot be launched using original Shortcut/Start menu tile. To co
if totalApp > 0 {
utils.PrintGreen("OK")
} else {
- utils.PrintError("Cannot backup app files. Reinstall Spotify and try again.")
+ utils.PrintError("Cannot backup app files. Reinstall Spotify and try again")
os.Exit(1)
}
@@ -65,16 +65,23 @@ Modded Spotify cannot be launched using original Shortcut/Start menu tile. To co
utils.PrintBold("Preprocessing:")
- preprocess.Start(
- spicetifyVersion,
- rawFolder,
- preprocess.Flag{
- DisableSentry: preprocSection.Key("disable_sentry").MustBool(false),
- DisableLogging: preprocSection.Key("disable_ui_logging").MustBool(false),
- RemoveRTL: preprocSection.Key("remove_rtl_rule").MustBool(false),
- ExposeAPIs: preprocSection.Key("expose_apis").MustBool(false)},
- )
- utils.PrintGreen("OK")
+ spotifyBasePath := spotifyPath
+ if spotifyBasePath == "" {
+ utils.PrintError("Spotify installation path not found. Cannot preprocess V8 snapshots")
+ } else {
+ preprocess.Start(
+ spicetifyVersion,
+ spotifyBasePath,
+ rawFolder,
+ preprocess.Flag{
+ DisableSentry: preprocSection.Key("disable_sentry").MustBool(false),
+ DisableLogging: preprocSection.Key("disable_ui_logging").MustBool(false),
+ RemoveRTL: preprocSection.Key("remove_rtl_rule").MustBool(false),
+ ExposeAPIs: preprocSection.Key("expose_apis").MustBool(false),
+ SpotifyVer: utils.GetSpotifyVersion(prefsPath)},
+ )
+ }
+ utils.PrintSuccess("Preprocessing completed")
err = utils.Copy(rawFolder, themedFolder, true, []string{".html", ".js", ".css"})
if err != nil {
@@ -82,7 +89,7 @@ Modded Spotify cannot be launched using original Shortcut/Start menu tile. To co
}
preprocess.StartCSS(themedFolder)
- utils.PrintGreen("OK")
+ utils.PrintSuccess("CSS replacing completed")
backupSection.Key("version").SetValue(utils.GetSpotifyVersion(prefsPath))
backupSection.Key("with").SetValue(spicetifyVersion)
@@ -96,7 +103,7 @@ func Clear() {
spotStat := spotifystatus.Get(appPath)
if !spotStat.IsBackupable() {
- utils.PrintWarning("Before clearing backup, please restore or re-install Spotify to stock state.")
+ utils.PrintWarning("Before clearing backup, please restore or re-install Spotify to stock state")
os.Exit(1)
}
@@ -137,5 +144,5 @@ func Restore() {
utils.Fatal(err)
}
- utils.PrintSuccess("Spotify is restored.")
+ utils.PrintSuccess("Spotify is restored")
}
diff --git a/src/cmd/cmd.go b/src/cmd/cmd.go
index 697ba1f772..f9bc8326b2 100644
--- a/src/cmd/cmd.go
+++ b/src/cmd/cmd.go
@@ -70,6 +70,9 @@ func InitPaths() {
os.Exit(1)
}
utils.PrintError(`Cannot detect Spotify location. Please manually set "spotify_path" in config-xpui.ini`)
+ if runtime.GOOS == "windows" {
+ utils.PrintInfo("Please make sure Spotify is not installed via Microsoft Store. If it is, please uninstall it and install Spotify with their web installer.")
+ }
os.Exit(1)
}
diff --git a/src/cmd/watch.go b/src/cmd/watch.go
index be1a06ebf4..d5fc151a3e 100644
--- a/src/cmd/watch.go
+++ b/src/cmd/watch.go
@@ -28,7 +28,7 @@ func Watch(liveUpdate bool) {
}
if len(themeFolder) == 0 {
- utils.PrintError(`Config "current_theme" is blank. No theme asset to watch.`)
+ utils.PrintError(`Config "current_theme" is blank. No theme asset to watch`)
os.Exit(1)
}
@@ -115,7 +115,7 @@ func WatchExtensions(extName []string, liveUpdate bool) {
}
if len(extPathList) == 0 {
- utils.PrintError("No extension to watch.")
+ utils.PrintError("No extension to watch")
os.Exit(1)
}
@@ -127,7 +127,7 @@ func WatchExtensions(extName []string, liveUpdate bool) {
pushExtensions("", filePath)
- utils.PrintSuccess(utils.PrependTime(`Extension "` + filePath + `" is updated.`))
+ utils.PrintSuccess(utils.PrependTime(`Extension "` + filePath + `" is updated`))
}, autoReloadFunc)
}
@@ -152,7 +152,7 @@ func WatchCustomApp(appName []string, liveUpdate bool) {
for _, v := range appNameList {
appPath, err := utils.GetCustomAppPath(v)
if err != nil {
- utils.PrintError(`Custom app "` + v + `" not found.`)
+ utils.PrintError(`Custom app "` + v + `" not found`)
continue
}
@@ -195,7 +195,7 @@ func WatchCustomApp(appName []string, liveUpdate bool) {
RefreshApps(appName)
- utils.PrintSuccess(utils.PrependTime(`Custom app "` + appName + `" is updated.`))
+ utils.PrintSuccess(utils.PrependTime(`Custom app "` + appName + `" is updated`))
}, autoReloadFunc)
}
@@ -210,7 +210,7 @@ func isValidForWatching() bool {
status := spotifystatus.Get(appDestPath)
if !status.IsModdable() {
- utils.PrintError(`You haven't applied. Run "spicetify apply" once before entering watch mode.`)
+ utils.PrintError(`You haven't applied. Run "spicetify apply" once before entering watch mode`)
return false
}
@@ -229,7 +229,7 @@ func startDebugger() {
autoReloadFunc = func() {
if utils.SendReload(&debuggerURL) != nil {
utils.PrintError("Could not Reload Spotify")
- utils.PrintInfo(`Close Spotify and run watch command again.`)
+ utils.PrintInfo(`Close Spotify and run watch command again`)
} else {
utils.PrintSuccess("Spotify reloaded")
}
diff --git a/src/preprocess/preprocess.go b/src/preprocess/preprocess.go
index 6ea27620ef..2f0099f670 100644
--- a/src/preprocess/preprocess.go
+++ b/src/preprocess/preprocess.go
@@ -10,9 +10,11 @@ import (
"path"
"path/filepath"
"regexp"
+ "runtime"
"strconv"
"strings"
+ "github.com/pterm/pterm"
"github.com/spicetify/cli/src/utils"
)
@@ -24,8 +26,32 @@ type Flag struct {
DisableLogging bool
// RemoveRTL removes all Right-To-Left CSS rules to simplify CSS files.
RemoveRTL bool
- // ExposeAPIs leaks some Spotify's API, functions, objects to Spicetify global object.
+ // ExposeAPIs leaks Spotify's API, functions, objects to Spicetify global object.
ExposeAPIs bool
+ SpotifyVer string
+}
+
+type Patch struct {
+ Name string
+ Regex string
+ Replacement func(submatches ...string) string
+ Once bool
+}
+
+type logPatch func(string)
+
+func applyPatches(input string, patches []Patch, report ...logPatch) string {
+ for _, patch := range patches {
+ if len(report) > 0 && report[0] != nil {
+ report[0](fmt.Sprintf("%s patch", patch.Name))
+ }
+ if patch.Once {
+ utils.ReplaceOnce(&input, patch.Regex, patch.Replacement)
+ } else {
+ utils.Replace(&input, patch.Regex, patch.Replacement)
+ }
+ }
+ return input
}
func readRemoteCssMap(tag string, cssTranslationMap *map[string]string) error {
@@ -59,8 +85,7 @@ func readLocalCssMap(cssTranslationMap *map[string]string) error {
return nil
}
-// Start preprocessing apps assets in extractedAppPath
-func Start(version string, extractedAppsPath string, flags Flag) {
+func Start(version string, spotifyBasePath string, extractedAppsPath string, flags Flag) {
appPath := filepath.Join(extractedAppsPath, "xpui")
var cssTranslationMap = make(map[string]string)
// readSourceMapAndGenerateCSSMap(appPath)
@@ -82,14 +107,93 @@ func Start(version string, extractedAppsPath string, flags Flag) {
readLocalCssMap(&cssTranslationMap)
}
+ verParts := strings.Split(flags.SpotifyVer, ".")
+ spotifyMajor, spotifyMinor, spotifyPatch := 0, 0, 0
+ if len(verParts) > 0 {
+ spotifyMajor, _ = strconv.Atoi(verParts[0])
+ }
+ if len(verParts) > 1 {
+ spotifyMinor, _ = strconv.Atoi(verParts[1])
+ }
+ if len(verParts) > 2 {
+ spotifyPatch, _ = strconv.Atoi(verParts[2])
+ }
+
+ frameworkResourcesPath := ""
+ switch runtime.GOOS {
+ case "darwin":
+ frameworkResourcesPath = filepath.Join(spotifyBasePath, "..", "Frameworks", "Chromium Embedded Framework.framework", "Resources")
+ case "windows", "linux":
+ frameworkResourcesPath = spotifyBasePath
+ default:
+ utils.PrintError("Unsupported OS for V8 snapshot finding: " + runtime.GOOS)
+ }
+
+ if frameworkResourcesPath != "" {
+ files, err := os.ReadDir(frameworkResourcesPath)
+ if err != nil {
+ utils.PrintWarning(fmt.Sprintf("Could not read directory %s for V8 snapshots: %v", frameworkResourcesPath, err))
+ } else {
+ for _, file := range files {
+ if !file.IsDir() && strings.HasPrefix(file.Name(), "v8_context_snapshot") && strings.HasSuffix(file.Name(), ".bin") {
+ binFilePath := filepath.Join(frameworkResourcesPath, file.Name())
+ utils.PrintInfo("Processing V8 snapshot file: " + binFilePath)
+
+ startMarker := []byte("var __webpack_modules__={")
+ endMarker := []byte("xpui-modules.js.map")
+
+ embeddedString, _, _, err := utils.ReadStringFromUTF16Binary(binFilePath, startMarker, endMarker)
+ if err != nil {
+ utils.PrintWarning(fmt.Sprintf("Could not process %s: %v", binFilePath, err))
+ utils.PrintInfo("If above warning says 'could not find start marker', you can safely ignore that error if you're on Spotify 1.2.63 or lower. However, if you're on 1.2.64 or higher, please report this issue")
+ continue
+ }
+
+ err = utils.CreateFile(filepath.Join(appPath, "xpui-modules.js"), embeddedString)
+ if err != nil {
+ utils.PrintWarning(fmt.Sprintf("Could not create xpui-modules.js: %v", err))
+ break
+ } else {
+ utils.PrintSuccess("Extracted V8 snapshot blob (remaining xpui modules) to xpui-modules.js")
+ break
+ }
+ }
+ }
+ }
+ }
+
+ var filesToPatch []string
filepath.Walk(appPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return nil
+ }
+ ext := filepath.Ext(info.Name())
+ if ext == ".js" || ext == ".css" || ext == ".html" {
+ filesToPatch = append(filesToPatch, path)
+ }
+ return nil
+ })
+
+ totalFiles := len(filesToPatch)
+
+ style := pterm.NewStyle(pterm.FgWhite, pterm.BgBlack)
+ bar, _ := pterm.DefaultProgressbar.WithTotal(totalFiles).WithTitle("Patching files...").WithTitleStyle(style).WithShowCount(true).Start()
+ printPatch := func(msg string) {
+ bar.UpdateTitle(msg)
+ }
+ for _, path := range filesToPatch {
+ info, err := os.Stat(path)
+ if err != nil {
+ continue
+ }
fileName := info.Name()
extension := filepath.Ext(fileName)
switch extension {
case ".js":
utils.ModifyFile(path, func(content string) string {
- if flags.DisableSentry && fileName == "xpui.js" {
+ if flags.DisableSentry && (fileName == "xpui.js" || fileName == "xpui-snapshot.js") {
+ printPatch("Disable Sentry")
content = disableSentry(content)
}
@@ -99,12 +203,26 @@ func Start(version string, extractedAppsPath string, flags Flag) {
if flags.ExposeAPIs {
switch fileName {
+ case "xpui-modules.js", "xpui-snapshot.js":
+ content = exposeAPIs_main(content, printPatch)
+ content = exposeAPIs_vendor(content, printPatch)
case "xpui.js":
- content = exposeAPIs_main(content)
+ content = exposeAPIs_main(content, printPatch)
+ if spotifyMajor >= 1 && spotifyMinor >= 2 && spotifyPatch >= 57 {
+ content = exposeAPIs_vendor(content, printPatch)
+ }
case "vendor~xpui.js":
- content = exposeAPIs_vendor(content)
+ content = exposeAPIs_vendor(content, printPatch)
+ }
+
+ if spotifyMajor >= 1 && spotifyMinor >= 2 && (spotifyPatch >= 28 && spotifyPatch <= 57) {
+ utils.ReplaceOnce(&content, `(typeName\])`, func(submatches ...string) string {
+ return fmt.Sprintf(`%s || []`, submatches[1])
+ })
}
+ content = additionalPatches(content, printPatch)
}
+ printPatch("CSS (JS): Patching our mappings into file")
for k, v := range cssTranslationMap {
utils.Replace(&content, k, func(submatches ...string) string {
return v
@@ -116,17 +234,20 @@ func Start(version string, extractedAppsPath string, flags Flag) {
})
case ".css":
utils.ModifyFile(path, func(content string) string {
+ printPatch("CSS: Patching our mappings into file")
for k, v := range cssTranslationMap {
utils.Replace(&content, k, func(submatches ...string) string {
return v
})
}
if flags.RemoveRTL {
+ printPatch("Remove RTL")
content = removeRTL(content)
}
- if fileName == "xpui.css" {
+ if fileName == "xpui.css" || fileName == "xpui-snapshot.css" {
+ printPatch("Extra CSS Patch")
content = content + `
- .main-gridContainer-fixedWidth{grid-template-columns: repeat(auto-fill, var(--column-width));width: calc((var(--column-count) - 1) * var(--grid-gap)) + var(--column-count) * var(--column-width));}.main-cardImage-imageWrapper{background-color: var(--card-color, #333);border-radius: 6px;-webkit-box-shadow: 0 8px 24px rgba(0, 0, 0, .5);box-shadow: 0 8px 24px rgba(0, 0, 0, .5);padding-bottom: 100%;position: relative;width:100%;}.main-cardImage-image,.main-card-imagePlaceholder{height: 100%;left: 0;position: absolute;top: 0;width: 100%}
+ .main-gridContainer-fixedWidth{grid-template-columns: repeat(auto-fill, var(--column-width));width: calc((var(--column-count) - 1) * var(--grid-gap)) + var(--column-count) * var(--column-width));}.main-cardImage-imageWrapper{background-color: var(--card-color, #333);border-radius: 6px;-webkit-box-shadow: 0 8px 24px rgba(0, 0, 0, .5);box-shadow: 0 8px 24px rgba(0, 0, 0, .5);padding-bottom: 100%;position: relative;width:100%;}.main-cardImage-image,.main-card-imagePlaceholder{height: 100%;left: 0;position: absolute;top: 0;width: 100%};.main-content-view{height:100%;}
`
}
return content
@@ -134,6 +255,7 @@ func Start(version string, extractedAppsPath string, flags Flag) {
case ".html":
utils.ModifyFile(path, func(content string) string {
+ printPatch("Inject wrapper/CSS")
var tags string
tags += "\n"
tags += "\n"
@@ -150,8 +272,9 @@ func Start(version string, extractedAppsPath string, flags Flag) {
return content
})
}
- return nil
- })
+
+ bar.Increment()
+ }
}
// StartCSS modifies all CSS files in extractedAppsPath to change
@@ -160,149 +283,213 @@ func StartCSS(extractedAppsPath string) {
appPath := filepath.Join(extractedAppsPath, "xpui")
filepath.Walk(appPath, func(path string, info os.FileInfo, err error) error {
// temp so text won't be black ._.
- if info.Name() == "pip-mini-player.css" {
+ if strings.HasPrefix(info.Name(), "pip-mini-player") && strings.HasSuffix(info.Name(), ".css") {
return nil
}
if filepath.Ext(info.Name()) == ".css" {
- utils.ModifyFile(path, colorVariableReplace)
+ utils.ModifyFile(path, func(content string) string {
+ return colorVariableReplace(content)
+ })
}
return nil
})
}
func colorVariableReplace(content string) string {
- utils.Replace(&content, "#181818", func(submatches ...string) string {
- return "var(--spice-player)"
- })
- utils.Replace(&content, "#212121", func(submatches ...string) string {
- return "var(--spice-player)"
- })
-
- utils.Replace(&content, "#282828", func(submatches ...string) string {
- return "var(--spice-card)"
- })
-
- utils.Replace(&content, "#121212", func(submatches ...string) string {
- return "var(--spice-main)"
- })
- utils.Replace(&content, `#(242424|1f1f1f)`, func(submatches ...string) string {
- return "var(--spice-main-elevated)"
- })
-
- utils.Replace(&content, "#1a1a1a", func(submatches ...string) string {
- return "var(--spice-highlight)"
- })
- utils.Replace(&content, "#2a2a2a", func(submatches ...string) string {
- return "var(--spice-highlight-elevated)"
- })
-
- utils.Replace(&content, "#000", func(submatches ...string) string {
- return "var(--spice-sidebar)"
- })
- utils.Replace(&content, "#000000", func(submatches ...string) string {
- return "var(--spice-sidebar)"
- })
-
- utils.Replace(&content, "white;", func(submatches ...string) string {
- return " var(--spice-text);"
- })
- utils.Replace(&content, "#fff", func(submatches ...string) string {
- return "var(--spice-text)"
- })
- utils.Replace(&content, "#ffffff", func(submatches ...string) string {
- return "var(--spice-text)"
- })
- utils.Replace(&content, "#f8f8f8", func(submatches ...string) string {
- return "var(--spice-text)"
- })
-
- utils.Replace(&content, "#b3b3b3", func(submatches ...string) string {
- return "var(--spice-subtext)"
- })
- utils.Replace(&content, "#a7a7a7", func(submatches ...string) string {
- return "var(--spice-subtext)"
- })
-
- utils.Replace(&content, "#1db954", func(submatches ...string) string {
- return "var(--spice-button)"
- })
- utils.Replace(&content, "#1877f2", func(submatches ...string) string {
- return "var(--spice-button)"
- })
-
- utils.Replace(&content, "#1ed760", func(submatches ...string) string {
- return "var(--spice-button-active)"
- })
- utils.Replace(&content, "#1fdf64", func(submatches ...string) string {
- return "var(--spice-button-active)"
- })
- utils.Replace(&content, "#169c46", func(submatches ...string) string {
- return "var(--spice-button-active)"
- })
-
- utils.Replace(&content, "#535353", func(submatches ...string) string {
- return "var(--spice-button-disabled)"
- })
-
- utils.Replace(&content, "#333", func(submatches ...string) string {
- return "var(--spice-tab-active)"
- })
- utils.Replace(&content, "#333333", func(submatches ...string) string {
- return "var(--spice-tab-active)"
- })
-
- utils.Replace(&content, "#7f7f7f", func(submatches ...string) string {
- return "var(--spice-misc)"
- })
-
- utils.Replace(&content, "#4687d6", func(submatches ...string) string {
- return "var(--spice-notification)"
- })
- utils.Replace(&content, "#2e77d0", func(submatches ...string) string {
- return "var(--spice-notification)"
- })
-
- utils.Replace(&content, "#e22134", func(submatches ...string) string {
- return "var(--spice-notification-error)"
- })
- utils.Replace(&content, "#cd1a2b", func(submatches ...string) string {
- return "var(--spice-notification-error)"
- })
-
- utils.Replace(&content, `rgba\(18,18,18,([\d\.]+)\)`, func(submatches ...string) string {
- return fmt.Sprintf("rgba(var(--spice-main),%s)", submatches[1])
- })
- utils.Replace(&content, `rgba\(40,40,40,([\d\.]+)\)`, func(submatches ...string) string {
- return fmt.Sprintf("rgba(var(--spice-card),%s)", submatches[1])
- })
- utils.Replace(&content, `rgba\(0,0,0,([\d\.]+)\)`, func(submatches ...string) string {
- return fmt.Sprintf("rgba(var(--spice-rgb-shadow),%s)", submatches[1])
- })
- utils.Replace(&content, `hsla\(0,0%,100%,\.9\)`, func(submatches ...string) string {
- return "rgba(var(--spice-rgb-text),.9)"
- })
- utils.Replace(&content, `hsla\(0,0%,100%,([\d\.]+)\)`, func(submatches ...string) string {
- return fmt.Sprintf("rgba(var(--spice-rgb-selected-row),%s)", submatches[1])
- })
+ colorPatches := []Patch{
+ {
+ Name: "CSS: --spice-player",
+ Regex: "#(181818|212121)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-player)"
+ },
+ },
+ {
+ Name: "CSS: --spice-card",
+ Regex: "#282828",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-card)"
+ },
+ },
+ {
+ Name: "CSS: --spice-main-elevated",
+ Regex: "#(242424|1f1f1f)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-main-elevated)"
+ },
+ },
+ {
+ Name: "CSS: --spice-main",
+ Regex: "#121212",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-main)"
+ },
+ },
+ {
+ Name: "CSS: --spice-card-elevated",
+ Regex: "#(242424|1f1f1f)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-card-elevated)"
+ },
+ },
+ {
+ Name: "CSS: --spice-highlight",
+ Regex: "#1a1a1a",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-highlight)"
+ },
+ },
+ {
+ Name: "CSS: --spice-highlight-elevated",
+ Regex: "#2a2a2a",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-highlight-elevated)"
+ },
+ },
+ {
+ Name: "CSS: --spice-sidebar",
+ Regex: "#(000|000000)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-sidebar)"
+ },
+ },
+ {
+ Name: "CSS: --spice-text",
+ Regex: "white;|#fff|#ffffff|#f8f8f8",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-text)"
+ },
+ },
+ {
+ Name: "CSS: --spice-subtext",
+ Regex: "#(b3b3b3|a7a7a7)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-subtext)"
+ },
+ },
+ {
+ Name: "CSS: --spice-button",
+ Regex: "#(1db954|1877f2)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-button)"
+ },
+ },
+ {
+ Name: "CSS: --spice-button-active",
+ Regex: "#(1ed760|1fdf64|169c46)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-button-active)"
+ },
+ },
+ {
+ Name: "CSS: --spice-button-disabled",
+ Regex: "#535353",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-button-disabled)"
+ },
+ },
+ {
+ Name: "CSS: --spice-tab-active",
+ Regex: "#(333|333333)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-tab-active)"
+ },
+ },
+ {
+ Name: "CSS: --spice-misc",
+ Regex: "#7f7f7f",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-misc)"
+ },
+ },
+ {
+ Name: "CSS: --spice-notification",
+ Regex: "#(4687d6|2e77d0)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-notification)"
+ },
+ },
+ {
+ Name: "CSS: --spice-notification-error",
+ Regex: "#(e22134|cd1a2b)",
+ Replacement: func(submatches ...string) string {
+ return "var(--spice-notification-error)"
+ },
+ },
+ {
+ Name: "CSS (rgba): --spice-main",
+ Regex: `rgba\(18,18,18,([\d\.]+)\)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("rgba(var(--spice-main),%s)", submatches[1])
+ },
+ },
+ {
+ Name: "CSS (rgba): --spice-card",
+ Regex: `rgba\(40,40,40,([\d\.]+)\)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("rgba(var(--spice-card),%s)", submatches[1])
+ },
+ },
+ {
+ Name: "CSS (rgba): --spice-rgb-shadow",
+ Regex: `rgba\(0,0,0,([\d\.]+)\)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("rgba(var(--spice-rgb-shadow),%s)", submatches[1])
+ },
+ },
+ {
+ Name: "CSS (hsla): --spice-rgb-text",
+ Regex: `hsla\(0,0%,100%,\.9\)`,
+ Replacement: func(submatches ...string) string {
+ return "rgba(var(--spice-rgb-text),.9)"
+ },
+ },
+ {
+ Name: "CSS (hsla): --spice-rgb-selected-row",
+ Regex: `hsla\(0,0%,100%,([\d\.]+)\)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("rgba(var(--spice-rgb-selected-row),%s)", submatches[1])
+ },
+ },
+ }
- return content
+ return applyPatches(content, colorPatches)
}
func colorVariableReplaceForJS(content string) string {
- utils.Replace(&content, `"#1db954"`, func(submatches ...string) string {
- return ` getComputedStyle(document.body).getPropertyValue("--spice-button").trim()`
- })
- utils.Replace(&content, `"#b3b3b3"`, func(submatches ...string) string {
- return ` getComputedStyle(document.body).getPropertyValue("--spice-subtext").trim()`
- })
- utils.Replace(&content, `"#ffffff"`, func(submatches ...string) string {
- return ` getComputedStyle(document.body).getPropertyValue("--spice-text").trim()`
- })
- utils.Replace(&content, `color:"white"`, func(submatches ...string) string {
- return `color:"var(--spice-text)"`
- })
- return content
+ colorVariablePatches := []Patch{
+ {
+ Name: "CSS (JS): --spice-button",
+ Regex: `"#1db954"`,
+ Replacement: func(submatches ...string) string {
+ return ` getComputedStyle(document.body).getPropertyValue("--spice-button").trim()`
+ },
+ },
+ {
+ Name: "CSS (JS): --spice-subtext",
+ Regex: `"#b3b3b3"`,
+ Replacement: func(submatches ...string) string {
+ return ` getComputedStyle(document.body).getPropertyValue("--spice-subtext").trim()`
+ },
+ },
+ {
+ Name: "CSS (JS): --spice-text",
+ Regex: `"#ffffff"`,
+ Replacement: func(submatches ...string) string {
+ return ` getComputedStyle(document.body).getPropertyValue("--spice-text").trim()`
+ },
+ },
+ {
+ Name: "CSS (JS): --spice-text white",
+ Regex: `color:"white"`,
+ Replacement: func(submatches ...string) string {
+ return `color:"var(--spice-text)"`
+ },
+ },
+ }
+
+ return applyPatches(content, colorVariablePatches)
}
func disableSentry(input string) string {
@@ -317,277 +504,452 @@ func disableSentry(input string) string {
}
func disableLogging(input string) string {
- utils.Replace(&input, `sp://logging/v3/\w+`, func(submatches ...string) string {
- return ""
- })
- utils.Replace(&input, `[^"\/]+\/[^"\/]+\/(public\/)?v3\/events`, func(submatches ...string) string {
- return ""
- })
-
- utils.Replace(&input, `key:"registerEventListeners",value:function\(\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"logInteraction",value:function\([\w,]+\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn {interactionId:null,pageInstanceId:null};", submatches[0])
- })
- utils.Replace(&input, `key:"logNonAuthInteraction",value:function\([\w,]+\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn {interactionId:null,pageInstanceId:null};", submatches[0])
- })
- utils.Replace(&input, `key:"logImpression",value:function\([\w,]+\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"logNonAuthImpression",value:function\([\w,]+\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"logNavigation",value:function\([\w,]+\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"handleBackgroundStates",value:function\(\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"createLoggingParams",value:function\([\w,]+\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"initSendingEvents",value:function\(\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"flush",value:function\(\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `(\{key:"send",value:function\([\w,]+\))\{[\d\w\s,{}()[\]\.,!\?=>&|;:_""]+?\}(\},\{key:"hasContext")`, func(submatches ...string) string {
- return fmt.Sprintf("%s{return;}%s", submatches[1], submatches[2])
- })
- utils.Replace(&input, `key:"lastFlush",value:function\(\)\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn Promise.resolve({fired:true});", submatches[0])
- })
- utils.Replace(&input, `key:"addItemInEventsStorage",value:function\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `key:"createLoggingParams",value:function\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn {interactionIds:null,pageInstanceIds:null};", submatches[0])
- })
- utils.Replace(&input, `key:"addEventsToESSData",value:function\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
-
- utils.Replace(&input, `registerEventListeners\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `logInteraction\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn {interactionId:null,pageInstanceId:null};", submatches[0])
- })
- utils.Replace(&input, `logImpression\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `logNavigation\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `handleBackgroundStates\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `initSendingEvents\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `sendEvents\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `storeEvent\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `lastFlush\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn Promise.resolve({fired:true});", submatches[0])
- })
- utils.Replace(&input, `addItemInEventsStorage\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
- utils.Replace(&input, `createLoggingParams\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn {interactionIds:null,pageInstanceIds:null};", submatches[0])
- })
- utils.Replace(&input, `addEventsToESSData\([^)]*\)\s*\{`, func(submatches ...string) string {
- return fmt.Sprintf("%sreturn;", submatches[0])
- })
-
- return input
+ loggingPatches := []Patch{
+ {
+ Name: "Remove sp://logging/v3/*",
+ Regex: `sp://logging/v3/\w+`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Remove /v3/events endpoints",
+ Regex: `[^"\/]+\/[^"\/]+\/(public\/)?v3\/events`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Disable registerEventListeners",
+ Regex: `key:"registerEventListeners",value:function\(\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logInteraction",
+ Regex: `key:"logInteraction",value:function\([\w,]+\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn {interactionId:null,pageInstanceId:null};", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logNonAuthInteraction",
+ Regex: `key:"logNonAuthInteraction",value:function\([\w,]+\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn {interactionId:null,pageInstanceId:null};", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logImpression",
+ Regex: `key:"logImpression",value:function\([\w,]+\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logNonAuthImpression",
+ Regex: `key:"logNonAuthImpression",value:function\([\w,]+\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logNavigation",
+ Regex: `key:"logNavigation",value:function\([\w,]+\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable handleBackgroundStates",
+ Regex: `key:"handleBackgroundStates",value:function\(\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable createLoggingParams",
+ Regex: `key:"createLoggingParams",value:function\([\w,]+\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable initSendingEvents",
+ Regex: `key:"initSendingEvents",value:function\(\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable flush",
+ Regex: `key:"flush",value:function\(\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable send",
+ Regex: `(\{key:"send",value:function\([\w,]+\))\{[\d\w\s,{}()[\]\.,!\?=>&|;:_""]+?\}(\},\{key:"hasContext")`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%s{return;}%s", submatches[1], submatches[2])
+ },
+ },
+ {
+ Name: "Disable lastFlush",
+ Regex: `key:"lastFlush",value:function\(\)\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn Promise.resolve({fired:true});", submatches[0])
+ },
+ },
+ {
+ Name: "Disable addItemInEventsStorage",
+ Regex: `key:"addItemInEventsStorage",value:function\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable createLoggingParams (new)",
+ Regex: `key:"createLoggingParams",value:function\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn {interactionIds:null,pageInstanceIds:null};", submatches[0])
+ },
+ },
+ {
+ Name: "Disable addEventsToESSData",
+ Regex: `key:"addEventsToESSData",value:function\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable registerEventListeners (new)",
+ Regex: `registerEventListeners\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logInteraction (new)",
+ Regex: `logInteraction\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn {interactionId:null,pageInstanceId:null};", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logImpression (new)",
+ Regex: `logImpression\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable logNavigation (new)",
+ Regex: `logNavigation\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable handleBackgroundStates (new)",
+ Regex: `handleBackgroundStates\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable initSendingEvents (new)",
+ Regex: `initSendingEvents\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable sendEvents",
+ Regex: `sendEvents\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable storeEvent",
+ Regex: `storeEvent\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable lastFlush (new)",
+ Regex: `lastFlush\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn Promise.resolve({fired:true});", submatches[0])
+ },
+ },
+ {
+ Name: "Disable addItemInEventsStorage (new)",
+ Regex: `addItemInEventsStorage\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ {
+ Name: "Disable createLoggingParams (new)",
+ Regex: `createLoggingParams\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn {interactionIds:null,pageInstanceIds:null};", submatches[0])
+ },
+ },
+ {
+ Name: "Disable addEventsToESSData (new)",
+ Regex: `addEventsToESSData\([^)]*\)\s*\{`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sreturn;", submatches[0])
+ },
+ },
+ }
+ return applyPatches(input, loggingPatches)
}
func removeRTL(input string) string {
- utils.Replace(&input, `}\[dir=ltr\]\s?`, func(submatches ...string) string {
- return "} "
- })
- utils.Replace(&input, `html\[dir=ltr\]`, func(submatches ...string) string {
- return "html"
- })
- utils.Replace(&input, `,\s?\[dir=rtl\].+?(\{.+?\})`, func(submatches ...string) string {
- return submatches[1]
- })
- utils.Replace(&input, `[\w\-\.]+\[dir=rtl\].+?\{.+?\}`, func(submatches ...string) string {
- return ""
- })
- utils.Replace(&input, `\}\[lang=ar\].+?\{.+?\}`, func(submatches ...string) string {
- return "}"
- })
- utils.Replace(&input, `\}\[dir=rtl\].+?\{.+?\}`, func(submatches ...string) string {
- return "}"
- })
- utils.Replace(&input, `\}html\[dir=rtl\].+?\{.+?\}`, func(submatches ...string) string {
- return "}"
- })
- utils.Replace(&input, `\}html\[lang=ar\].+?\{.+?\}`, func(submatches ...string) string {
- return "}"
- })
- utils.Replace(&input, `\[lang=ar\].+?\{.+?\}`, func(submatches ...string) string {
- return ""
- })
- utils.Replace(&input, `html\[dir=rtl\].+?\{.+?\}`, func(submatches ...string) string {
- return ""
- })
- utils.Replace(&input, `html\[lang=ar\].+?\{.+?\}`, func(submatches ...string) string {
- return ""
- })
- utils.Replace(&input, `\[dir=rtl\].+?\{.+?\}`, func(submatches ...string) string {
- return ""
- })
+ rtlPatches := []Patch{
+ {
+ Name: "Remove }[dir=ltr]",
+ Regex: `}\[dir=ltr\]\s?`,
+ Replacement: func(submatches ...string) string {
+ return "} "
+ },
+ },
+ {
+ Name: "Remove html[dir=ltr]",
+ Regex: `html\[dir=ltr\]`,
+ Replacement: func(submatches ...string) string {
+ return "html"
+ },
+ },
+ {
+ Name: "Remove ', [dir=rtl]' selectors",
+ Regex: `,\s?\[dir=rtl\].+?(\{.+?\})`,
+ Replacement: func(submatches ...string) string {
+ return submatches[1]
+ },
+ },
+ {
+ Name: "Remove [something][dir=rtl] blocks",
+ Regex: `[\w\-\.]+\[dir=rtl\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Remove }[lang=ar] blocks",
+ Regex: `\}\[lang=ar\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return "}"
+ },
+ },
+ {
+ Name: "Remove }[dir=rtl] blocks",
+ Regex: `\}\[dir=rtl\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return "}"
+ },
+ },
+ {
+ Name: "Remove }html[dir=rtl] blocks",
+ Regex: `\}html\[dir=rtl\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return "}"
+ },
+ },
+ {
+ Name: "Remove }html[lang=ar] blocks",
+ Regex: `\}html\[lang=ar\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return "}"
+ },
+ },
+ {
+ Name: "Remove [lang=ar] blocks",
+ Regex: `\[lang=ar\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Remove html[dir=rtl] blocks",
+ Regex: `html\[dir=rtl\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Remove html[lang=ar] blocks",
+ Regex: `html\[lang=ar\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Remove [dir=rtl] blocks",
+ Regex: `\[dir=rtl\].+?\{.+?\}`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ }
- return input
+ return applyPatches(input, rtlPatches)
}
-func exposeAPIs_main(input string) string {
- // Show Notification
- utils.Replace(
- &input,
- `(?:\w+ |,)([\w$]+)=(\([\w$]+=[\w$]+\.dispatch)`,
- func(submatches ...string) string {
- return fmt.Sprintf(`;globalThis.Spicetify.showNotification=(message,isError=false,msTimeout)=>%s({message,feedbackType:isError?"ERROR":"NOTICE",msTimeout});const %s=%s`, submatches[1], submatches[1], submatches[2])
- })
-
- // Remove list of exclusive shows
- utils.Replace(
- &input,
- `\["spotify:show.+?\]`,
- func(submatches ...string) string {
- return "[]"
- })
-
- // Remove Star Wars easter eggs since it aggressively
- // listens to keystroke, checking URIs at all time
- // TODO: to fix
- utils.Replace(
- &input,
- `\w+\(\)\.createElement\(\w+,\{onChange:this\.handleSaberStateChange\}\),`,
- func(submatches ...string) string {
- return ""
- })
-
- utils.Replace(
- &input,
- `"data-testid":`,
- func(submatches ...string) string {
- return `"":`
- })
-
- // Spicetify._platform
- utils.Replace(
- &input,
- `(setTitlebarHeight[\w(){}<>:.,&$!=;""?!#% ]+)(\{version:[a-zA-Z_\$][\w\$]*,)`,
- func(submatches ...string) string {
- return fmt.Sprintf("%sSpicetify._platform=%s", submatches[1], submatches[2])
- })
-
- // Redux store
- utils.Replace(
- &input,
- `(,[\w$]+=)(([$\w,.:=;(){}]+\(\{session:[\w$]+,features:[\w$]+,seoExperiment:[\w$]+\}))`,
- func(submatches ...string) string {
- return fmt.Sprintf("%sSpicetify.Platform.ReduxStore=%s", submatches[1], submatches[2])
- })
-
- // React Component: Platform Provider
- utils.Replace(
- &input,
- `(,[$\w]+=)((function\([\w$]{1}\)\{var [\w$]+=[\w$]+\.platform,[\w$]+=[\w$]+\.children,)|(\(\{platform:[\w$]+,children:[\w$]+\}\)=>\{))`,
- func(submatches ...string) string {
- return fmt.Sprintf("%sSpicetify.ReactComponent.PlatformProvider=%s", submatches[1], submatches[2])
- })
-
- // Prevent breaking popupLyrics
- utils.Replace(
- &input,
- `document.pictureInPictureElement&&\(\w+.current=[!\w]+,document\.exitPictureInPicture\(\)\),\w+\.current=null`,
- func(submatches ...string) string {
- return ""
- })
-
- // GraphQL definitions <=1.2.30
- utils.Replace(
- &input,
- `((?:\w+ ?)?[\w$]+=)(\{kind:"Document",definitions:\[\{(?:\w+:[\w"]+,)+name:\{(?:\w+:[\w"]+,?)+value:("\w+"))`,
- func(submatches ...string) string {
- return fmt.Sprintf("%sSpicetify.GraphQL.Definitions[%s]=%s", submatches[1], submatches[3], submatches[2])
- })
-
- // GraphQL definitons >=1.2.31
- utils.Replace(
- &input,
- `(=new [\w_\$][\w_\$\d]*\.[\w_\$][\w_\$\d]*\("(\w+)","(query|mutation)","[\w\d]{64}",null\))`,
- func(submatches ...string) string {
- return fmt.Sprintf(`=Spicetify.GraphQL.Definitions["%s"]%s`, submatches[2], submatches[1])
- })
+func additionalPatches(input string, report logPatch) string {
+ graphQLPatches := []Patch{
+ {
+ Name: "GraphQL definitions (<=1.2.30)",
+ Regex: `((?:\w+ ?)?[\w$]+=)(\{kind:"Document",definitions:\[\{(?:\w+:[\w"]+,)+name:\{(?:\w+:[\w"]+,?)+value:("\w+"))`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sSpicetify.GraphQL.Definitions[%s]=%s", submatches[1], submatches[3], submatches[2])
+ },
+ },
+ {
+ Name: "GraphQL definitons (>=1.2.31)",
+ Regex: `(=new [\w_\$][\w_\$\d]*\.[\w_\$][\w_\$\d]*\("(\w+)","(query|mutation)","[\w\d]{64}",null\))`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf(`=Spicetify.GraphQL.Definitions["%s"]%s`, submatches[2], submatches[1])
+ },
+ },
+ }
- // Spotify Custom Snackbar Interfaces
- utils.Replace(
- &input,
- `\b\w\s*\(\)\s*[^;,]*enqueueCustomSnackbar:\s*(\w)\s*[^;]*;`,
- func(submatches ...string) string {
- return fmt.Sprintf("%sSpicetify.Snackbar.enqueueCustomSnackbar=%s;", submatches[0], submatches[1])
- })
+ return applyPatches(input, graphQLPatches, report)
+}
- // >= 1.2.38
- utils.Replace(
- &input,
- `(=)[^=]*\(\)\.enqueueCustomSnackbar;`,
- func(submatches ...string) string {
- return fmt.Sprintf("=Spicetify.Snackbar.enqueueCustomSnackbar%s;", submatches[0])
- })
+func exposeAPIs_main(input string, report logPatch) string {
+ inputContextMenu := utils.FindFirstMatch(input, `.*(?:value:"contextmenu"|"[^"]*":"context-menu")`)
+ if len(inputContextMenu) > 0 {
+ croppedInput := inputContextMenu[0]
+ react := utils.FindLastMatch(croppedInput, `([a-zA-Z_\$][\w\$]*)\.useRef`)[1]
+ candicates := utils.FindLastMatch(croppedInput, `\(\{[^}]*menu:([a-zA-Z_\$][\w\$]*),[^}]*trigger:([a-zA-Z_\$][\w\$]*),[^}]*triggerRef:([a-zA-Z_\$][\w\$]*)`)
+ oldCandicates := utils.FindLastMatch(croppedInput, `([a-zA-Z_\$][\w\$]*)=[\w_$]+\.menu[^}]*,([a-zA-Z_\$][\w\$]*)=[\w_$]+\.trigger[^}]*,([a-zA-Z_\$][\w\$]*)=[\w_$]+\.triggerRef`)
+ var menu, trigger, target string
+ if len(oldCandicates) != 0 {
+ menu = oldCandicates[1]
+ trigger = oldCandicates[2]
+ target = oldCandicates[3]
+ } else if len(candicates) != 0 {
+ menu = candicates[1]
+ trigger = candicates[2]
+ target = candicates[3]
+ } else {
+ menu = "e.menu"
+ trigger = "e.trigger"
+ target = "e.triggerRef"
+ }
- utils.Replace(
- &input,
- `\(\({[^}]*,\s*imageSrc`,
- func(submatches ...string) string {
- return fmt.Sprintf("Spicetify.Snackbar.enqueueImageSnackbar=%s", submatches[0])
+ utils.Replace(&input, `\(0,([\w_$]+)\.jsx\)\((?:[\w_$]+\.[\w_$]+,\{value:"contextmenu"[^}]+\}\)\}\)|"[\w-]+",\{[^}]+:"context-menu"[^}]+\}\))`, func(submatches ...string) string {
+ return fmt.Sprintf("(0,%s.jsx)((Spicetify.ContextMenuV2._context||(Spicetify.ContextMenuV2._context=%s.createContext(null))).Provider,{value:{props:%s?.props,trigger:%s,target:%s},children:%s})", submatches[1], react, menu, trigger, target, submatches[0])
})
-
- // Menu hook
- utils.Replace(&input, `("Menu".+?children:)([\w$][\w$\d]*)`, func(submatches ...string) string {
- return fmt.Sprintf("%s[Spicetify.ContextMenuV2.renderItems(),%s].flat()", submatches[1], submatches[2])
- })
-
- croppedInput := utils.FindFirstMatch(input, `.*value:"contextmenu"`)[0]
- react := utils.FindLastMatch(croppedInput, `([a-zA-Z_\$][\w\$]*)\.useRef`)[1]
- candicates := utils.FindLastMatch(croppedInput, `\(\{[^}]*menu:([a-zA-Z_\$][\w\$]*),[^}]*trigger:([a-zA-Z_\$][\w\$]*),[^}]*triggerRef:([a-zA-Z_\$][\w\$]*)`)
- oldCandicates := utils.FindLastMatch(croppedInput, `([a-zA-Z_\$][\w\$]*)=[\w_$]+\.menu[^}]*,([a-zA-Z_\$][\w\$]*)=[\w_$]+\.trigger[^}]*,([a-zA-Z_\$][\w\$]*)=[\w_$]+\.triggerRef`)
- var menu, trigger, target string
- if len(oldCandicates) != 0 {
- menu = oldCandicates[1]
- trigger = oldCandicates[2]
- target = oldCandicates[3]
- } else if len(candicates) != 0 {
- menu = candicates[1]
- trigger = candicates[2]
- target = candicates[3]
- } else {
- menu = "e.menu"
- trigger = "e.trigger"
- target = "e.triggerRef"
}
- utils.Replace(&input, `\(0,([\w_$]+)\.jsx\)\([\w_$]+\.[\w_$]+,\{value:"contextmenu"[^\}]+\}\)\}\)`, func(submatches ...string) string {
- return fmt.Sprintf("(0,%s.jsx)((Spicetify.ContextMenuV2._context||(Spicetify.ContextMenuV2._context=%s.createContext(null))).Provider,{value:{props:%s?.props,trigger:%s,target:%s},children:%s})", submatches[1], react, menu, trigger, target, submatches[0])
- })
+ xpuiPatches := []Patch{
+ {
+ Name: "showNotification patch",
+ Regex: `(?:\w+ |,)([\w$]+)=(\([\w$]+=[\w$]+\.dispatch)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf(`;globalThis.Spicetify.showNotification=(message,isError=false,msTimeout)=>%s({message,feedbackType:isError?"ERROR":"NOTICE",msTimeout});const %s=%s`, submatches[1], submatches[1], submatches[2])
+ },
+ },
+ {
+ Name: "Remove list of exclusive shows",
+ Regex: `\["spotify:show.+?\]`,
+ Replacement: func(submatches ...string) string {
+ return "[]"
+ },
+ },
+ {
+ Name: "Remove Star Wars easter eggs",
+ Regex: `\w+\(\)\.createElement\(\w+,\{onChange:this\.handleSaberStateChange\}\),`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Remove data-testid",
+ Regex: `"data-testid":`,
+ Replacement: func(submatches ...string) string {
+ return `"":`
+ },
+ },
+ {
+ Name: "Expose PlatformAPI",
+ Regex: `((?:setTitlebarHeight|registerFactory)[\w(){}<>:.,&$!=;""?!#% ]+)(\{version:[a-zA-Z_\$][\w\$]*,)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sSpicetify._platform=%s", submatches[1], submatches[2])
+ },
+ },
+ {
+ Name: "Redux store",
+ Regex: `(,[\w$]+=)(([$\w,.:=;(){}]+\(\{session:[\w$]+,features:[\w$]+,seoExperiment:[\w$]+\}))`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sSpicetify.Platform.ReduxStore=%s", submatches[1], submatches[2])
+ },
+ },
+ {
+ Name: "React Component: Platform Provider",
+ Regex: `(,[$\w]+=)((function\([\w$]{1}\)\{var [\w$]+=[\w$]+\.platform,[\w$]+=[\w$]+\.children,)|(\(\{platform:[\w$]+,children:[\w$]+\}\)=>\{))`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sSpicetify.ReactComponent.PlatformProvider=%s", submatches[1], submatches[2])
+ },
+ },
+ {
+ Name: "Prevent breaking popupLyrics",
+ Regex: `document.pictureInPictureElement&&\(\w+.current=[!\w]+,document\.exitPictureInPicture\(\)\),\w+\.current=null`,
+ Replacement: func(submatches ...string) string {
+ return ""
+ },
+ },
+ {
+ Name: "Spotify Custom Snackbar Interfaces (<=1.2.37)",
+ Regex: `\b\w\s*\(\)\s*[^;,]*enqueueCustomSnackbar:\s*(\w)\s*[^;]*;`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sSpicetify.Snackbar.enqueueCustomSnackbar=%s;", submatches[0], submatches[1])
+ },
+ },
+ {
+ Name: "Spotify Custom Snackbar Interfaces (>=1.2.38)",
+ Regex: `(=)[^=]*\(\)\.enqueueCustomSnackbar;`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("=Spicetify.Snackbar.enqueueCustomSnackbar%s;", submatches[0])
+ },
+ },
+ {
+ Name: "Spotify Image Snackbar Interface",
+ Regex: `\(\({[^}]*,\s*imageSrc`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("Spicetify.Snackbar.enqueueImageSnackbar=%s", submatches[0])
+ },
+ },
+ {
+ Name: "React Component: Navigation for navLinks",
+ Regex: `(;const [\w\d]+=)((?:\(0,[\w\d]+\.memo\))[\(\d,\w\.\){:}=]+\=[\d\w]+\.[\d\w]+\.getLocaleForURLPath\(\))`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%sSpicetify.ReactComponent.Navigation=%s", submatches[1], submatches[2])
+ },
+ Once: true,
+ },
+ {
+ Name: "Context Menu V2",
+ Regex: `("Menu".+?children:)([\w$][\w$\d]*)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%s[Spicetify.ContextMenuV2.renderItems(),%s].flat()", submatches[1], submatches[2])
+ },
+ },
+ }
- return input
+ return applyPatches(input, xpuiPatches, report)
}
-func exposeAPIs_vendor(input string) string {
+func exposeAPIs_vendor(input string, report logPatch) string {
// URI
utils.Replace(
&input,
@@ -596,6 +958,45 @@ func exposeAPIs_vendor(input string) string {
return fmt.Sprintf(`,(globalThis.Spicetify.URI=%s)%s`, submatches[1], submatches[0])
})
+ vendorPatches := []Patch{
+ {
+ Name: "Spicetify.URI",
+ Regex: `,(\w+)\.prototype\.toAppType`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf(`,(globalThis.Spicetify.URI=%s)%s`, submatches[1], submatches[0])
+ },
+ },
+ {
+ Name: "Map styled-components classes",
+ Regex: `(\w+ [\w$_]+)=[\w$_]+\([\w$_]+>>>0\)`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%s=Spicetify._getStyledClassName(arguments,this)", submatches[1])
+ },
+ },
+ {
+ Name: "Tippy.js",
+ Regex: `([\w\$_]+)\.setDefaultProps=`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("Spicetify.Tippy=%s;%s", submatches[1], submatches[0])
+ },
+ },
+ {
+ Name: "Flipper components",
+ Regex: `([\w$]+)=((?:function|\()([\w$.,{}()= ]+(?:springConfig|overshootClamping)){2})`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("%s=Spicetify.ReactFlipToolkit.spring=%s", submatches[1], submatches[2])
+ },
+ },
+ {
+ // https://github.com/iamhosseindhv/notistack
+ Name: "Snackbar",
+ Regex: `\w+\s*=\s*\w\.call\(this,[^)]+\)\s*\|\|\s*this\)\.enqueueSnackbar`,
+ Replacement: func(submatches ...string) string {
+ return fmt.Sprintf("Spicetify.Snackbar=%s", submatches[0])
+ },
+ },
+ }
+
// URI after 1.2.4
if !strings.Contains(input, "Spicetify.URI") {
URIObj := regexp.MustCompile(`(?:class ([\w$_]+)\{constructor|([\w$_]+)=function\(\)\{function ?[\w$_]+)\([\w$.,={}]+\)\{[\w !?:=.,>&(){}[\];]*this\.hasBase62Id`).FindStringSubmatch(input)
@@ -620,39 +1021,7 @@ func exposeAPIs_vendor(input string) string {
}
}
- // Mapping styled-components classes
- utils.Replace(
- &input,
- `(\w+ [\w$_]+)=[\w$_]+\([\w$_]+>>>0\)`,
- func(submatches ...string) string {
- return fmt.Sprintf("%s=Spicetify._getStyledClassName(arguments,this)", submatches[1])
- })
-
- // Tippy
- utils.Replace(
- &input,
- `([\w\$_]+)\.setDefaultProps=`,
- func(submatches ...string) string {
- return fmt.Sprintf("Spicetify.Tippy=%s;%s", submatches[1], submatches[0])
- })
-
- // Flipper components
- utils.Replace(
- &input,
- `([\w$]+)=((?:function|\()([\w$.,{}()= ]+(?:springConfig|overshootClamping)){2})`,
- func(submatches ...string) string {
- return fmt.Sprintf("%s=Spicetify.ReactFlipToolkit.spring=%s", submatches[1], submatches[2])
- })
-
- // Snackbar https://github.com/iamhosseindhv/notistack
- utils.Replace(
- &input,
- `\w+\s*=\s*\w\.call\(this,[^)]+\)\s*\|\|\s*this\)\.enqueueSnackbar`,
- func(submatches ...string) string {
- return fmt.Sprintf("Spicetify.Snackbar=%s", submatches[0])
- })
-
- return input
+ return applyPatches(input, vendorPatches, report)
}
type githubRelease = utils.GithubRelease
diff --git a/src/utils/config.go b/src/utils/config.go
index 6d669f3472..83a6a70148 100644
--- a/src/utils/config.go
+++ b/src/utils/config.go
@@ -70,7 +70,7 @@ func ParseConfig(configPath string) Config {
content: getDefaultConfig(),
}
defaultConfig.Write()
- PrintSuccess("Default config-xpui.ini generated.")
+ PrintSuccess("Default config-xpui.ini generated")
return defaultConfig
}
@@ -90,7 +90,7 @@ func ParseConfig(configPath string) Config {
}
if needRewrite {
- PrintSuccess("Config is updated.")
+ PrintSuccess("Config is updated")
cfg.SaveTo(configPath)
}
@@ -129,13 +129,13 @@ func getDefaultConfig() *ini.File {
prefsFilePath := FindPrefFilePath()
if len(spotifyPath) == 0 {
- PrintError("Could not detect Spotify location.")
+ PrintError("Could not detect Spotify location")
} else {
configLayout["Setting"]["spotify_path"] = spotifyPath
}
if len(prefsFilePath) == 0 {
- PrintError("Could not detect \"prefs\" file location.")
+ PrintError("Could not detect \"prefs\" file location")
} else {
configLayout["Setting"]["prefs_path"] = prefsFilePath
}
@@ -193,7 +193,7 @@ func FindPrefFilePath() string {
path = WinXPrefs()
}
if len(path) == 0 {
- PrintError("No valid path options found, ensure you have Spotify installed and have ran it for at least 30 seconds.")
+ PrintError("No valid path options found, ensure you have Spotify installed and have ran it for at least 30 seconds")
}
return path
diff --git a/src/utils/file-utils.go b/src/utils/file-utils.go
new file mode 100644
index 0000000000..d0086f7d4b
--- /dev/null
+++ b/src/utils/file-utils.go
@@ -0,0 +1,86 @@
+package utils
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "os"
+ "unicode/utf16"
+)
+
+func ReadStringFromUTF16Binary(inputFile string, startMarker []byte, endMarker []byte) (string, int, int, error) {
+ fileContent, err := os.ReadFile(inputFile)
+ if err != nil {
+ return "", -1, -1, fmt.Errorf("error reading file %s: %w", inputFile, err)
+ }
+
+ isUTF16LE := false
+ if len(fileContent) >= 2 && fileContent[0] == 0xFF && fileContent[1] == 0xFE {
+ isUTF16LE = true
+ }
+
+ if !isUTF16LE && len(fileContent) > 100 && fileContent[1] == 0x00 {
+ isUTF16LE = true
+ }
+
+ var startIdx, endIdx int
+ var contentToSearch []byte
+ var searchStartMarker, searchEndMarker []byte
+
+ if !isUTF16LE {
+ return "", -1, -1, fmt.Errorf("file is not in UTF-16LE format: %s", inputFile)
+ }
+
+ contentToSearch = fileContent[2:]
+ searchStartMarker = encodeUTF16LE(startMarker)
+ searchEndMarker = encodeUTF16LE(endMarker)
+
+ startIdx = bytes.Index(contentToSearch, searchStartMarker)
+ if startIdx == -1 {
+ return "", -1, -1, fmt.Errorf("start marker not found: %s", string(startMarker))
+ }
+
+ searchSpace := contentToSearch[startIdx+len(searchStartMarker):]
+ endIdx = bytes.Index(searchSpace, searchEndMarker)
+ if endIdx == -1 {
+ return "", -1, -1, fmt.Errorf("end marker not found after start index %d: %s", startIdx+len(searchStartMarker), string(endMarker))
+ }
+
+ stringContentBytes := contentToSearch[startIdx : startIdx+len(searchStartMarker)+endIdx+len(searchEndMarker)]
+
+ decodedStringBytes, err := decodeUTF16LE(stringContentBytes)
+ if err != nil {
+ return "", -1, -1, fmt.Errorf("error decoding UTF-16LE content: %w", err)
+ }
+
+ // Adjust indices to be byte offsets in the original file
+ originalStartIdx := 2 + startIdx
+ originalEndIdx := 2 + endIdx + len(stringContentBytes)
+ return string(decodedStringBytes), originalStartIdx, originalEndIdx, nil
+}
+
+// Helper function to encode a byte slice (assumed UTF-8) to UTF-16LE
+func encodeUTF16LE(data []byte) []byte {
+ utf16Bytes := utf16.Encode([]rune(string(data)))
+ byteSlice := make([]byte, len(utf16Bytes)*2)
+ for i, r := range utf16Bytes {
+ binary.LittleEndian.PutUint16(byteSlice[i*2:], r)
+ }
+
+ return byteSlice
+}
+
+// Helper function to decode a byte slice (UTF-16LE) to UTF-8
+func decodeUTF16LE(data []byte) ([]byte, error) {
+ if len(data)%2 != 0 {
+ return nil, fmt.Errorf("invalid UTF-16LE data length")
+ }
+
+ uint16s := make([]uint16, len(data)/2)
+ for i := 0; i < len(data)/2; i++ {
+ uint16s[i] = binary.LittleEndian.Uint16(data[i*2:])
+ }
+
+ runes := utf16.Decode(uint16s)
+ return []byte(string(runes)), nil
+}
diff --git a/src/utils/isAdmin/unix.go b/src/utils/isAdmin/unix.go
new file mode 100644
index 0000000000..a28ef1a1f4
--- /dev/null
+++ b/src/utils/isAdmin/unix.go
@@ -0,0 +1,13 @@
+//go:build !windows
+// +build !windows
+
+package isAdmin
+
+import "os"
+
+func Check(bypassAdminCheck bool) bool {
+ if bypassAdminCheck {
+ return false
+ }
+ return os.Geteuid() == 0
+}
diff --git a/src/utils/isAdmin/windows.go b/src/utils/isAdmin/windows.go
new file mode 100644
index 0000000000..842d27b3ec
--- /dev/null
+++ b/src/utils/isAdmin/windows.go
@@ -0,0 +1,31 @@
+//go:build windows
+// +build windows
+
+package isAdmin
+
+import (
+ "golang.org/x/sys/windows"
+)
+
+func Check(bypassAdminCheck bool) bool {
+ if bypassAdminCheck {
+ return false
+ }
+
+ var sid *windows.SID
+ err := windows.AllocateAndInitializeSid(
+ &windows.SECURITY_NT_AUTHORITY,
+ 2,
+ windows.SECURITY_BUILTIN_DOMAIN_RID,
+ windows.DOMAIN_ALIAS_RID_ADMINS,
+ 0, 0, 0, 0, 0, 0,
+ &sid)
+ if err != nil {
+ return false
+ }
+ defer windows.FreeSid(sid)
+
+ token := windows.Token(0)
+ member, err := token.IsMember(sid)
+ return err == nil && member
+}
diff --git a/src/utils/path-utils.go b/src/utils/path-utils.go
index c158d66cea..fbb259e4d6 100644
--- a/src/utils/path-utils.go
+++ b/src/utils/path-utils.go
@@ -24,6 +24,52 @@ func MigrateConfigFolder() {
}
}
+func MigrateFolders() {
+ backupPath := filepath.Join(GetSpicetifyFolder(), "Backup")
+ extractedPath := filepath.Join(GetSpicetifyFolder(), "Extracted")
+
+ if _, err := os.Stat(backupPath); err == nil {
+ newBackupPath := GetStateFolder("Backup")
+ oldAbs, err := filepath.Abs(backupPath)
+ if err != nil {
+ Fatal(err)
+ }
+ newAbs, err := filepath.Abs(newBackupPath)
+ if err != nil {
+ Fatal(err)
+ }
+
+ if oldAbs != newAbs {
+ PrintBold("Migrating spicetify state (Backup, Extracted) folders")
+ err := Copy(backupPath, newBackupPath, true, nil)
+ if err != nil {
+ Fatal(err)
+ }
+ os.RemoveAll(backupPath)
+ }
+ }
+
+ if _, err := os.Stat(extractedPath); err == nil {
+ newExtractedPath := GetStateFolder("Extracted")
+ oldAbs, err := filepath.Abs(extractedPath)
+ if err != nil {
+ Fatal(err)
+ }
+ newAbs, err := filepath.Abs(newExtractedPath)
+ if err != nil {
+ Fatal(err)
+ }
+ if oldAbs != newAbs {
+ PrintBold("Migrating spicetify state (Backup, Extracted) folders")
+ err := Copy(extractedPath, newExtractedPath, true, nil)
+ if err != nil {
+ Fatal(err)
+ }
+ os.RemoveAll(extractedPath)
+ }
+ }
+}
+
func ReplaceEnvVarsInString(input string) string {
var replacements []string
for _, v := range os.Environ() {
diff --git a/src/utils/utils.go b/src/utils/utils.go
index c2658e2cf1..e4f32d18df 100644
--- a/src/utils/utils.go
+++ b/src/utils/utils.go
@@ -223,6 +223,15 @@ func ModifyFile(path string, repl func(string) string) {
os.WriteFile(path, []byte(content), 0700)
}
+// CreateFile creates a file with given path and content.
+func CreateFile(path string, content string) error {
+ err := os.WriteFile(path, []byte(content), 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
// GetSpotifyVersion .
func GetSpotifyVersion(prefsPath string) string {
pref, err := ini.Load(prefsPath)