// ==UserScript== // @name SpotiKit++ desktop // @namespace https://github.com/Myst1cX/SpotiKit // @version 7.0.15 // @description SpotiKit - Visual premium UI overlay for Spotify and ad banner blocking. Amoled theme. Restores the old Now Playing View button. // @author kit_fogos, Myst1cX (fork) // @match *://open.spotify.com/* // @match *://www.spotify.com/* // @match *://payments.spotify.com/* // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @run-at document-start // @homepageURL https://github.com/Myst1cX/SpotiKit // @supportURL https://github.com/Myst1cX/SpotiKit/issues // @updateURL https://raw.githubusercontent.com/Myst1cX/SpotiKit/main/SpotiwebJS.user.js // @downloadURL https://raw.githubusercontent.com/Myst1cX/SpotiKit/main/SpotiwebJS.user.js // ==/UserScript== // RESOLVED (7.0.fork, Myst1cX): // First change: // Added proper linking for installing the script via an userscript manager // Removed obsolete function that attempted to intercept and block audio ads. // Second big change: // Text-replacement pass is now scoped to changed nodes instead of walking // the whole document on every mutation, and also catches in-place text // updates (characterData), not just added/removed nodes. // Removed the redundant setInterval ad-cleanup now that the MutationObserver // covers the same ground on its own. // Every swap the script makes is now recorded (selector, before/after text, // times applied) and can be printed as a table from the userscript-manager // menu. // Added "already done" guards to every hide-only action (upgrade button, // install link, premium menu link, Planes Premium/Premium Plans sweeps, // compact-banner rebuild, and the Try/Prueba button) so each is only // touched and logged once per element instead of re-firing on every tick. // Third change: // Added forceEnglish(), which overrides navigator.language/languages to // en-US and, on www.spotify.com, redirects non-English-region locale paths // (e.g. /mx/, /es/) to /us/ so the page itself loads in English instead of // relying on text-replacement afterward. Runs once on script start. // Since the site now always renders in English, dropped the Spanish // alternatives from the button/table/XPath matchers (obtener, conseguir, // explorar, ver, prueba, gratuito, "Planes Premium") and the Spanish // "Descarga canciones..." string check — they're dead weight now. // Updated the two hardcoded account-page redirect URLs from /mx/ to /us/ // to match. // Fourth big change: // Added two independent userscript-manager menu toggles (via // GM_registerMenuCommand + GM_setValue/GM_getValue). One toggle covers // open.spotify.com, the other covers www.spotify.com and payments.spotify.com (plan payment blockers/redirects), // each independent and GM-storage-backed, both enabled by default. // Added the missing @grant GM_setValue / @grant GM_getValue lines these need. // Also fixed forceEnglish(): it previously only spoofed // navigator.language/languages and redirected non-English www.spotify.com // paths, both of which only affect a single page load. It never touched // the account-level language setting saved server-side at // open.spotify.com/preferences, which is what actually drives the English // aria-labels (e.g. "Open Your Library") this script's selectors depend // on. Added forceEnglishAccountSetting(), which flips that setting to // English via a hidden iframe, verifies the flip stuck on the next load, // and retries a capped number of times if it didn't. // Fifth big change: // Replaced the NPV block ported from spotify-web-lyrics-plus (zero-width // collapse of `.zjCIcN96KsMfWwRo`) with Spotifuck's real guard system, // ported 1:1: clickNP()/closeNowPlay()/isNpvOpen()/npvGuardObserver, plus // our own npBtn (next to the lyrics button) and an album-art click listener. // NPV now opens/closes for real via the native toggle instead of staying // squashed - only npBtn/album-art clicks are authorized opens, anything // else gets auto-closed. // Also swapped the direct `.wJiY1vDfuci2a4db` hide (unreliable here) for // Spotifuck's actual method - forcing #main-view/--panel-gap/100vw, which // crops the native toggle off-screen as a side effect - turns out that CSS // does relate to the toggle staying hidden after all, just indirectly. // Scoped it behind `html:not(.npv-open)` (unlike spotifuck's unconditional // version) so it doesn't squash NPV's own panel while legitimately open. // Sixth big change: // Fixed setupNpvWidgetTrigger (the player-bar album art click listener): // it unconditionally set userOpenedNPV=true on every click, but the album // art is a native TOGGLE - the second click closes NPV, and that close // never runs through closeNowPlay() (the only other place that resets the // flag), so userOpenedNPV was left stuck true after closing via album art. // The next unrelated native NPV open (e.g. a playlist's play button // auto-opening NPV) was then wrongly trusted as authorized and never // auto-closed by the guard. Now computes willOpen from isNpvOpen() before // the click, same as clickNP() already did, and sets the flag to match // either direction. // Ported Spotifuck's SPFDBG debug-logging system (dbg(event, selector, // details), off by default, toggled via its own userscript-manager menu // command) into every click handler and NPV-guard state-change function: // the two spoofed premium buttons, the Edit profile/Payment method banner // buttons, the payments-page blocker, closeNowPlay(), clickNP(), // npvGuardObserver's autoclose branch, setupNpvButton, the album-art // listener above, and the Queue/Connect panel-trigger listeners. Declared // at module scope (outside both IIFEs and the NPV guard block) so all // three sections can log through it. // Investigated the Queue/Connect panel's close (X) button being scrolled // out of the visible viewport (present in the DOM, just off-screen) - // attempted porting Spotifuck's #main-view height-clipping CSS // (min-height:0 + overflow:hidden, without Spotifuck's bottom-nav/player // height subtraction, which doesn't apply here) but this did NOT fix it in // testing. Reverted; root cause still open. // Seventh big change: // Finished the dbg() debug-logging coverage: logChange, applyReplacements, // scanText, run()'s DOM-scanning pass, handleMutations/startObserver (the // actual debounce), forceEnglish, forceEnglishAccountSetting, // applyEnglishToLanguageSelect, and the ad-slot-removal observer didn't log // through dbg() yet - they now do. // forceEnglish/forceEnglishAccountSetting/applyEnglishToLanguageSelect used // to trace via plain console.log('SpotiwebJS: ...') instead - refactored // those into dbg() so they're gated behind the same toggle and filterable // by "SPFDBG" like everything else. // Moved the "Debug Logging (console)" menu command to the bottom of the // userscript-manager menu (was 3rd, now 4th/last, after the two spoof // toggles and "Show everything replaced so far"). // Eighth big change - unionized three places that had drifted from Spotifuck: // a) Renamed run() to runPremium() to match Spotifuck's name for the same // DOM-scan pass - purely cosmetic, no behavior change. // b) Tried making forceEnglish() call forceEnglishAccountSetting() // unconditionally, same as Spotifuck, instead of only on // open.spotify.com. Confirmed it added no real behavior on // www.spotify.com/payments.spotify.com (that function's hidden iframe is // hardcoded to https://open.spotify.com/preferences, so calling it from // those hosts just hits the existing cross-origin catch block and gives // up) - a silent no-op, not an error, so it went unnoticed until now. // NOTE: this comment previously claimed a hostname gate was already // "kept" around the call, but the call itself was actually unconditional // at the time - now fixed for real (see the tenth change below), so this // entry is corrected to describe what was actually true then vs. now. // c) Ad-slot removal was previously ungated by host here - it ran on every // matched page (open/www/payments) and relied solely on the runtime // premiumSpoofEnabledHere() check to no-op elsewhere, meaning a live // MutationObserver plus repeated no-op queries on www/payments pages // where these ad selectors can never match anything (they're specific to // the open.spotify.com web player). Now wrapped in the same // `if (HOST_IS_OPEN)` gate Spotifuck uses, so the observer isn't even // created on hosts where it can never do anything. // d) Switched @run-at from document-idle to document-start (matching // Spotifuck) so the navigator.language/navigator.languages spoof at the // top of forceEnglish() actually runs before Spotify's own scripts read // it on open.spotify.com - at document-idle that spoof was set too late // to affect anything Spotify computed from it during load. // Ninth big change: // a) add-new-card-button (on the "Add new card"/payment method flow) also // appears on www.spotify.com's own account pages // (/account/payment-methods/, aliased to/from // /account/saved-payment-cards/ - confirmed same page reachable under // either path), entirely separate from payments.spotify.com (the actual // checkout flow already blocked below via BLOCK_SELECTOR). The // payments.spotify.com blocker is gated by // `window.location.hostname === 'payments.spotify.com'`, so it never ran // on www.spotify.com at all - wrong host, regardless of which regional // locale prefix was in the path (si-en, us, mx-es, etc). Added a second, // narrower blocker scoped to HOST_IS_WWW for this case: no "DONT WASTE // YOUR MONEY" overlay (this is account management, not checkout - // replacing the whole page would be overkill), just the same // preventDefault/stopPropagation no-op on the button, gated behind the // same www.spotify.com toggle (premiumSpoofEnabledHere()) as everything // else scoped to that host. Path-matched via // location.pathname.includes(...) against both known paths rather than // a hardcoded locale segment, since the region prefix varies per account // and forceEnglish()/getCurrentRegionPrefix() deliberately leave bare // codes alone in places - a fixed '/si-en/' or '/us/' check would miss // every other region. // b) forceEnglishAccountSetting() was being called unconditionally at the // bottom of forceEnglish() (no hostname check around the call itself) - // it fired on every matched host, including www.spotify.com and // payments.spotify.com, where its hidden iframe (hardcoded to // https://open.spotify.com/preferences) is cross-origin and can never // succeed - it always hit the catch block and gave up, silently, doing // nothing but wasting an iframe load every page load on those two hosts. // Now gated behind `if (HOST_IS_OPEN)`, with an `else` branch logging // the skip via dbg(), so the call - and its iframe - only happens on // open.spotify.com, where it can actually do something. No behavior // change on open.spotify.com; www/payments simply stop paying for a call // that never accomplished anything. Scope note: this only affects the // ACCOUNT-LEVEL language setting (open.spotify.com/preferences), which // drives the English aria-labels the open.spotify.com selectors depend // on - it has nothing to do with the region/locale-PATH redirect for // www.spotify.com (e.g. /si-sl/ -> /si-en/), which is the separate block // covered in (d) below. // c) dbg() coverage audit - checked every click handler, // GM_registerMenuCommand callback, and state-changing function against // the Sixth/Seventh change's dbg() coverage claims. Found and fixed two // real gaps: forceEnglish()'s skip of forceEnglishAccountSetting() on // non-open.spotify.com hosts (from (b) above) wasn't logged, and the two // "Visual Premium Spoof" GM_registerMenuCommand toggles // (open.spotify.com / www.spotify.com) flipped a persisted flag and // reloaded but never logged the toggle itself - the one user-triggered // write in the whole script with zero trace. Added dbg() calls for both. // Deliberately NOT adding dbg() to setupNpvButton/setupNpvWidgetTrigger/ // setupOtherPanelTriggers' "target not found yet" early returns - those // three run on a 1-second polling loop while the page is still loading, // so logging every failed poll would spam the console every second // until the player bar renders. Left unlogged on purpose, not a missed // spot. // d) Region-code data audit against Spotify's real // /select-your-country-region/ listing (uploaded 2026-07-14 snapshot, // 184 countries). Verified NO_ENGLISH_VARIANT (9: ad, be, cd, ch, dz, es, // lu, ma, tn) and ENGLISH_BARE_CODES (42 entries) both exactly match the // real data - every entry checks out, and every excluded ambiguous bare // country (ar, at, fr, jp, pl, etc. - 35 of them) is correctly left out // to fall back to /us. Found one real bug: getCurrentRegionPrefix() only // checked ENGLISH_BARE_CODES for bare-path URLs, never ENGLISH_IS_BARE. // "ba" (Bosnia) and "mk" (North Macedonia) are bare+dash countries where // the BARE code is the English one (ba-bs/mk-mk are the local-language // variants) - forceEnglish() already redirects those countries TO their // bare form for exactly that reason, but getCurrentRegionPrefix() didn't // recognize it, so the Edit profile/Payment method banner buttons would // send a just-correctly-redirected Bosnian/Macedonian user to // /us/account/... instead of /ba/... or /mk/.... Fixed by also checking // ENGLISH_IS_BARE.has(country) alongside ENGLISH_BARE_CODES.has(country). // e) The region-path redirect inside forceEnglish() (the www.spotify.com // locale-suffix redirect, e.g. /si-sl/ -> /si-en/) was gated only by // `location.hostname === 'www.spotify.com'`, with no // premiumSpoofEnabledHere() check - so turning "Visual Premium Spoof // (www.spotify.com)" off correctly stopped/reverted page modifications, // but this redirect kept firing regardless of the toggle. Decided the // toggle should mean "don't touch this site at all" rather than // narrowly "don't spoof premium status/UI", so this block now also // checks premiumSpoofEnabledHere() and no-ops (with a dbg() log) when // the www.spotify.com toggle is off. Left the navigator.language/ // navigator.languages spoof at the top of forceEnglish() unconditional, // since other selectors may depend on it regardless of this toggle (and // it's harmless/inert on its own - it doesn't touch the page or // redirect anywhere). The /intl-xx/ prefix redirect further down and the // open.spotify.com account-setting flip from (b) are untouched by this. // Considered and declined: reverting a URL that was already redirected // before the toggle was turned off (e.g. sending /si-en/ back to // /si-sl/). The redirect is one-directional by design and keeps no // record of what a URL was before it fired, so "back-pedaling" would // mean adding new state (e.g. stashing the pre-redirect path in // sessionStorage) purely to support reverting - and even then it'd only // work within the same tab/session, and couldn't tell a script-driven // redirect apart from a URL the user genuinely navigated to on purpose. // Not worth the complexity for a case that resolves itself the next // time the user naturally lands on a non-English URL anyway. If this // becomes a real pain point later, the sessionStorage approach above is // the way to do it - not implemented here. // Tenth change: // Corrected a stale/inaccurate claim in the comment above // setupNpvButton()/setupNpvWidgetTrigger()/setupOtherPanelTriggers()'s // npvSetupInterval poll: it said Spotifuck Mobile's own indefinite pfint // polling already gave those two elements equivalent coverage there, so // SpotiwebJS needed its own loop only because it runs at document-idle with // no equivalent loop in place. Checked Mobile directly - pfint polls // indefinitely, but only for the play button; once that's found, ffDone // latches true and Mobile's addCSSJSHack() (which wires these three) never // runs again, so Mobile was actually only giving them one fixed 2s retry, // not indefinite coverage. Comment corrected, and Spotifuck Mobile (v.7.1) has since been // given this same indefinite-poll pattern for real (see its own changelog). // No code change here - SpotiwebJS's own npvSetupInterval was already // correct; only the comment's claim about Mobile was wrong. // Eleventh change: // Ported Spotifuck Mobile's AMOLED pure-black CSS block (from r0/e.java line // 207), which had been missed until now despite everything else on this // list being a deliberate 1:1 port from Spotifuck. Added as its own // GM_addStyle call right after the existing .__sp_curr one below: overrides // the Encore dark-theme background custom properties (--background-base, // --background-highlight, --background-elevated-base, // --background-elevated-highlight, --background-elevated-press, // --background-tinted-base) to #000, and force-blacks // aside[data-testid=now-playing-bar] the same way Mobile does. Unconditional // and always-on, same as Mobile - not gated behind either premium-spoof // toggle, since it's pure cosmetic theming with no relation to what those // toggles control. // A straight copy of Mobile's block (without !important on the six custom // properties) left almost everything grey except the exact elements the // rule's other selector touched directly (the player bar via // aside[data-testid=now-playing-bar]) - not just the sidebar/library, but // the main container view too. Root cause: custom properties cascade // from the nearest ancestor that declares them, not by selector // specificity - Spotify's own code redeclares some of these vars locally on // panels closer to the main view/sidebar/library roots than this rule sits, // so a plain override loses that proximity race regardless of how the // selector is written. // Mobile itself was never broken this way and needed no change here: its // Sixth big change (bottom-nav/library-overlay) block already carries // `.YourLibraryX{background:var(--background-elevated-base)!important}`, // an explicit !important pin on exactly the surface that would otherwise // lose the same proximity race - added for unrelated bottom-nav-overlay // reasons, but it happens to solve this exact cascade problem for Mobile's // library/sidebar. SpotiwebJS has no equivalent of that block (desktop // doesn't use Mobile's bottom-nav-driven library overlay), so it had no // comparable !important anywhere pinning those surfaces. Fixed here by // adding !important directly to all six custom properties instead - a // blanket fix at the source rather than Mobile's narrower per-surface one, // appropriate since desktop lacks the overlay code Mobile's fix piggybacks // on. // Twelfth change: A feature I later scrapped (ignore) // Thirteenth change: // Re-checked every GM_registerMenuCommand callback and click handler // against the Ninth change's (c) coverage audit. Found two real gaps // that audit missed: "Show everything replaced so far" and // "Debug Logging (console)" themselves - the very act of printing the // replacement log or flipping the debug flag was never logged, the // same "one user-triggered action with zero trace" problem the Ninth // change already fixed for the two Visual Premium Spoof toggles. Both // now log via dbg() (Debug Logging's own toggle logs via a raw // console.log matching dbg()'s exact output shape instead of dbg() // itself, since dbg() is gated behind debugLoggingEnabled() and would // otherwise never print the one line that announces logging just turned on). // Fourteenth change - metadata fix: // www.spotify.com was only matched on five narrow paths (*/account/*, // */premium/*, */duo/*, */student/*, */family/*). Since www.spotify.com routes // client-side (pushState, no full reload), landing anywhere else first (e.g. // the homepage) meant the script never got injected at all, even after // navigating into a matched path. Fixed by matching the whole www.spotify.com // origin. payments.spotify.com already matched its whole origin and is only // ever reached via a real (non-SPA) navigation, so it didn't have this bug - // left as-is aside from the @match style pass below. // Side effect: the premium-spoof text/badge replacements and forceEnglish()'s // region redirect now also run on other www.spotify.com pages, not just the // original five. // All three @match lines now use *:// instead of https://, purely cosmetic // (Spotify enforces HTTPS on all three anyway). // Fifteenth change: // forceEnglish()'s /intl-xx/ URL correction and account-setting flip // (forceEnglishAccountSetting()) used to run immediately at document-start, // before Spotify's own SPA had even started hydrating. A user reported // open.spotify.com getting permanently stuck never finishing its initial // load with the script enabled; disabling the script, letting the page load // fully, manually setting the account language to English, then re-enabling // fixed it for them - consistent with the location.replace() call inside // forceEnglish() racing Spotify's own startup rather than a logic bug in the // correction itself. That work now lives in a new function, // runIntlCorrectionOnceReady(), which waits for // [data-testid="control-button-playpause"] to exist (the persistent // player-bar's play/pause button, present as soon as the app shell mounts, // even before anything is playing, and not localized like its aria-label // is) and only then runs the correction, exactly once. navigator.language // spoofing and the separate www.spotify.com region-path redirect are // untouched and still run immediately at document-start as before. // // What runIntlCorrectionOnceReady() actually does on a mismatched /intl-xx/ // load: the page still renders in the account's saved language for a moment // (unavoidable - we can't know whether the account setting needs flipping // without checking it first, via a hidden /preferences iframe, which takes // a moment). Previously that check was preceded by a separate, usually // pointless location.replace() that stripped the /intl-xx/ prefix from the // URL before the check even started - pointless because if the account // setting really was still non-English, the server just redirected straight // back to /intl-xx/ on the very next load anyway, wasting a navigation. That // strip is gone now; the script goes straight to the account-setting check // via forceEnglishAccountSetting(), which takes the /intl-xx/-stripped // target path as a new optional parameter. Whatever that check decides to // do next - flip the setting and verify, or just confirm it's already // English, or give up because the check itself failed - it now navigates // straight to the stripped target path in a single location.replace(), // instead of reloading back onto the still-prefixed /intl-xx/ URL and // relying on a later load to strip it. Net effect: one navigation off // /intl-xx/ instead of two, and the page is never left stuck on /intl-xx/ // even in the failure cases. // --- Per-site visual premium spoof toggles --- // Declared at module scope (not inside either IIFE below) because both the // text/badge-spoof IIFE and the separate ad-slot-removal IIFE need to read // premiumSpoofEnabledHere() - it's the single switch that gates both. // Mirrors Spotifuck v6.6's split: the in-player spoof (open.spotify.com) // and the account-site/payments spoof (www.spotify.com, payments.spotify.com) // are independent, GM-storage-backed, and default to enabled. const SPOOF_OPEN_KEY = 'spotiweb_premSpoofOpen'; const SPOOF_WWW_KEY = 'spotiweb_premSpoofWWW'; const HOST_IS_OPEN = location.hostname === 'open.spotify.com'; const HOST_IS_WWW = location.hostname === 'www.spotify.com' || location.hostname === 'payments.spotify.com'; function getFlag(key) { try { return typeof GM_getValue === 'function' ? GM_getValue(key, true) : true; } catch (e) { return true; } } function setFlag(key, val) { try { if (typeof GM_setValue === 'function') GM_setValue(key, val); } catch (e) {} } function premiumSpoofEnabledHere() { if (HOST_IS_OPEN) return getFlag(SPOOF_OPEN_KEY); if (HOST_IS_WWW) return getFlag(SPOOF_WWW_KEY); return false; } // --- Debug logging (ported from Spotifuck) --- // Off by default; console.log spam would otherwise fire on every click for // every ordinary user. Declared at module scope (not inside either IIFE // below, and not inside the NPV guard block) since all three sections need // it. Every click handler / state-change function logs through dbg() with // the same shape: dbg('event name', 'selector used to find the element', // { ...state/details }). Filter your console by "SPFDBG" to isolate just // this script's activity. const DEBUG_KEY = 'spotiweb_debugLog'; let printReplacementLog; // assigned inside the first IIFE below; forward-declared here so the module-scope menu command can call it function debugLoggingEnabled() { try { return typeof GM_getValue === 'function' ? GM_getValue(DEBUG_KEY, false) : false; } catch (e) { return false; } } function dbg(event, selector, details) { if (!debugLoggingEnabled()) return; console.log(`%c[SPFDBG] ${event}`, 'color:#1ed760;font-weight:bold;', 'selector:', selector, details || ''); } console.log('%c[SPFDBG] filter this console by "SPFDBG" to see every button click, selector, and resulting view change', 'color:#1ed760;font-weight:bold;'); if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand( (getFlag(SPOOF_OPEN_KEY) ? '✅' : '❌') + ' Visual Premium Spoof (open.spotify.com)', () => { const next = !getFlag(SPOOF_OPEN_KEY); dbg('menu: Visual Premium Spoof (open.spotify.com) toggled', 'GM_registerMenuCommand', { from: getFlag(SPOOF_OPEN_KEY), to: next, action: 'reloading' }); setFlag(SPOOF_OPEN_KEY, next); location.reload(); } ); GM_registerMenuCommand( (getFlag(SPOOF_WWW_KEY) ? '✅' : '❌') + ' Visual Premium Spoof (www.spotify.com)', () => { const next = !getFlag(SPOOF_WWW_KEY); dbg('menu: Visual Premium Spoof (www.spotify.com) toggled', 'GM_registerMenuCommand', { from: getFlag(SPOOF_WWW_KEY), to: next, action: 'reloading' }); setFlag(SPOOF_WWW_KEY, next); location.reload(); } ); GM_registerMenuCommand('📋 Show everything replaced so far (console)', () => { dbg('menu: Show everything replaced so far (console) clicked', 'GM_registerMenuCommand', {}); printReplacementLog(); alert('Current text replacements have been logged to the console. Open DevTools (Press F12 or Right click and Inspect), then select the Logs tab under Console to view it.'); }); GM_registerMenuCommand( (debugLoggingEnabled() ? '✅' : '❌') + ' Debug Logging (console)', () => { const next = !debugLoggingEnabled(); // Not dbg() - dbg() is gated behind debugLoggingEnabled(), which is // still false at the moment logging gets turned on, so it would // swallow the one line that announces logging just turned on. // Raw console.log matching dbg()'s exact output shape instead. console.log('%c[SPFDBG] menu: Debug Logging (console) toggled', 'color:#1ed760;font-weight:bold;', 'selector:', 'GM_registerMenuCommand', { from: debugLoggingEnabled(), to: next, action: 'reloading' }); setFlag(DEBUG_KEY, next); location.reload(); } ); } (function() { 'use strict'; const PINK = '#FFD2D7'; const GREEN = '#1ed760'; GM_addStyle(` .__sp_curr { display:inline-block; background:#535353; color:#fff; font-size:11px; font-weight:700; padding:3px 8px; border-radius:3px; text-transform:uppercase; letter-spacing:.4px; } `); // AMOLED pure black mode - ported from Spotifuck Mobile (r0/e.java line 207). // Was missing here; the rest of SpotiwebJS ports Spotifuck 1:1 but this // small self-contained block got skipped. Overrides the Encore dark-theme // background variables to true #000 and force-blacks the player bar, // same as spotifuck-mobile. // AMOLED pure black mode - ported from Spotifuck Mobile (r0/e.java line // 207). IMPORTANT: the six custom properties below MUST carry // !important. Custom properties resolve from the NEAREST ancestor that // declares them, not by selector specificity - Spotify's own code // redeclares some of these same vars locally on panels closer to the // main view/sidebar/library roots than this rule sits, so a plain // declaration here loses that proximity race even though the selector // itself is fine. !important is the one thing that wins regardless of // proximity, since importance is weighed before specificity/origin/order // in the cascade. Confirmed by testing: without !important, only the // literal elements the other selector here touches directly (the player // bar) went black - the main container view, sidebar, and library panel // all stayed grey; adding !important to just these six lines - no extra // per-element rules needed - made every themed surface black. // Mobile itself doesn't need this: its Sixth big change block already // has `.YourLibraryX{background:var(--background-elevated-base) // !important}`, which independently pins its library/sidebar surface // against the same proximity race. SpotiwebJS has no equivalent of that // block (desktop doesn't use Mobile's bottom-nav-driven library // overlay), so !important on the six vars here is doing the job that // rule does for Mobile - just as a blanket fix at the source instead of // a narrower per-surface one. // (An earlier version of this block also force-set // #Desktop_LeftSidebar_Id/.YourLibraryX directly as a belt-and-suspenders // measure; removed since !important alone was sufficient.) GM_addStyle(` .encore-dark-theme { --background-base: #000 !important; --background-highlight: #000 !important; --background-elevated-base: #000 !important; --background-elevated-highlight: #000 !important; --background-elevated-press: #000 !important; --background-tinted-base: #000 !important; } aside[data-testid=now-playing-bar] { background: #000 !important; box-shadow: none; border-top: 1px solid #666; } `); const REPLACE = { "Spotify Free": "Premium Individual", "1 Free account": "1 Premium account", "1 free account": "1 Premium account", "Music with ads": "Listen to music ad-free", "Music listening with ad breaks": "Listen to music ad-free", "Shuffle play": "Play any song", "Songs play in shuffle": "Play any song", "Online only": "Download for offline listening", "Streaming only": "Download for offline listening", "No downloads": "Download for offline listening", "Basic audio quality": "Very high audio quality", "Normal audio quality": "Very high audio quality", "Limited skips": "Unlimited skips", "Free plan": "Premium Individual", }; const replacementLog = new Map(); function logChange(selector, from, to) { dbg('logChange: replacement recorded', selector, { from, to }); const key = `${selector}\u0000${from}\u0000${to}`; const existing = replacementLog.get(key); if (existing) { existing.times_applied++; } else { replacementLog.set(key, { selector, old_text: from, new_text: to, times_applied: 1 }); } } printReplacementLog = function() { if (replacementLog.size === 0) { console.log('[SpotiKit] Nothing has been replaced yet.'); return; } console.log(`[SpotiKit] ${replacementLog.size} distinct change(s) made so far:`); console.table(Array.from(replacementLog.values())); } function applyReplacements(node) { let v = node.nodeValue; if (v == null) return; let c = false; for (const [from, to] of Object.entries(REPLACE)) { if (v.includes(from)) { v = v.replaceAll(from, to); c = true; logChange('(page text)', from, to); } } if (c) { dbg('applyReplacements: text node updated', '(text node)', { before: node.nodeValue, after: v }); node.nodeValue = v; } } function scanText(root) { if (!root) return; dbg('scanText: DOM scan pass', 'TreeWalker(root, SHOW_TEXT)', { root: root === document.body ? 'document.body' : (root.id || root.className || root.nodeName) }); const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); let n; while (n = w.nextNode()) applyReplacements(n); } // Ported from Spotifuck's ForceEn (Android forces the app locale to // English before loading its WebView). There's no app Configuration to // set here, so the browser-side equivalent is: spoof navigator's // reported language, and — on www.spotify.com, where locale is a path // segment (e.g. /mx/, /es/) — redirect off any non-English region so // the page itself renders in English rather than relying on the // find-and-replace pass to catch up after the fact. // Shared between forceEnglish()'s redirect decision and // getCurrentRegionPrefix()'s button-URL decision, so both use the same // facts rather than duplicating/drifting apart. // // NO_ENGLISH_VARIANT / ENGLISH_IS_BARE: verified against Spotify's own // /select-your-country-region/ listing (2026-07-14) - covers countries // that DO have an xx-yy dash variant, where "yy=en" or "append -en" // isn't the right answer. // - 9 have NO English variant at all, dash or bare: ad, be, cd, ch, // dz, es, lu, ma, tn. // - 2 have English as the BARE code, not "-en": ba, mk. const NO_ENGLISH_VARIANT = new Set(['ad', 'be', 'cd', 'ch', 'dz', 'es', 'lu', 'ma', 'tn']); const ENGLISH_IS_BARE = new Set(['ba', 'mk']); // ENGLISH_BARE_CODES: countries whose ONLY storefront is a bare code // (no dash variant exists to compare against) that is confirmed // English - either the region page is explicitly labelled "(English)", // or the country's official/majority language is verifiably English // (Commonwealth Caribbean, Anglophone Africa, Anglophone Oceania). // Deliberately excludes bare codes where the language is ambiguous or // unverified from the listing alone (e.g. cy, ge, am, bt, mn, la, uz, // al, mc, li) - those fall through to the /us fallback below rather // than being guessed at. const ENGLISH_BARE_CODES = new Set([ 'us', 'uk', 'au', 'nz', 'ie', 'mt', 'kh', 'ag', 'bb', 'bs', 'dm', 'gd', 'gy', 'jm', 'kn', 'lc', 'tt', 'vc', 'bw', 'gh', 'gm', 'lr', 'ls', 'mu', 'mw', 'ng', 'rw', 'sl', 'sz', 'zm', 'zw', 'fj', 'fm', 'ki', 'mh', 'nr', 'pg', 'pw', 'sb', 'to', 'tv', 'ws', ]); /** * getCurrentRegionPrefix - Returns the region path segment (e.g. "us", * "si-en", "mk", "hk-zh") that should prefix any www.spotify.com/account * link we build ourselves. * * For dash-suffixed URLs (xx-yy), this trusts the current URL as-is, * since forceEnglish() runs at @run-at document-start and will have * already corrected it before this code executes. * * For bare-code URLs (xx, no suffix), forceEnglish() deliberately * leaves those untouched (see its comment), so a bare code in the URL * is NOT proof it's English - e.g. /jp/ or /de/ would reach here * unmodified. So this function checks the bare code against * ENGLISH_BARE_CODES (plain English-only countries) and ENGLISH_IS_BARE * (countries like "ba"/"mk" whose bare code IS the English variant, * distinct from their own dash variant, e.g. "ba" vs "ba-bs") - both * are cases forceEnglish() itself already treats as landing correctly * on English. Everything else (including ambiguous/unverified ones) * falls back to /us so the buttons always land somewhere readable * rather than carrying forward an unconfirmed or non-English locale. */ function getCurrentRegionPrefix() { const m = location.pathname.match(/^\/([a-z]{2})(-[a-z]{2})?\//i); if (!m) return 'us'; const country = m[1].toLowerCase(); if (m[2]) return country + m[2].toLowerCase(); // dash variant, already corrected upstream // Bare path: valid if it's a plain English-only country (ENGLISH_BARE_CODES) // OR a country whose bare code IS the English variant, distinct from its own // dash variant (ENGLISH_IS_BARE, e.g. "ba" English vs "ba-bs" Bosnian) - // forceEnglish() redirects those countries TO their bare form precisely // because it's the English one, so this has to recognize it too or button // URLs built here would wrongly fall back to /us right after that redirect. return (ENGLISH_BARE_CODES.has(country) || ENGLISH_IS_BARE.has(country)) ? country : 'us'; } function forceEnglish() { dbg('forceEnglish: spoofing navigator.language', 'navigator.language/languages', { value: 'en-US' }); try { Object.defineProperty(navigator, 'language', { get: () => 'en-US', configurable: true }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'], configurable: true }); } catch (e) {} if (location.hostname === 'www.spotify.com') { // Gated behind the same www.spotify.com toggle // (premiumSpoofEnabledHere()) as everything else scoped to this // host. Previously this redirect ran unconditionally regardless // of the toggle, so turning "Visual Premium Spoof // (www.spotify.com)" off correctly stopped/reverted page // modifications but still silently redirected e.g. /si-sl/ to // /si-en/ - the one piece of www.spotify.com behavior that // wasn't actually off when the toggle said it was. if (!premiumSpoofEnabledHere()) { dbg('forceEnglish: skipping region-path redirect', location.pathname, { reason: 'Visual Premium Spoof (www.spotify.com) is off' }); } else { // Trust the language suffix when the URL has one (xx-yy, e.g. // /si-sl/, /de-en/, /hk-zh/) - Spotify's own site consistently // uses a 2-letter language code there, so `yy !== 'en'` is a // reliable signal regardless of which country `xx` is. // Bare codes (no suffix, e.g. /jp/, /de/, /us/) are left alone // here: whether a bare code is English-language varies country // by country with no clean pattern, so there's no safe way to // redirect the whole PAGE on those without assuming something // unverified. (getCurrentRegionPrefix() above handles this // differently for button URLs specifically, where landing // somewhere readable matters more than preserving locale.) const m = location.pathname.match(/^\/([a-z]{2})-([a-z]{2})(\/.*)?$/i); if (m) { const country = m[1].toLowerCase(); const lang = m[2].toLowerCase(); if (lang !== 'en') { let target; if (NO_ENGLISH_VARIANT.has(country)) { // No English storefront exists for this country at // all (dash or bare) - fall back to /us rather than // leaving the user on a non-English page. target = '/us' + (m[3] || '/'); } else if (ENGLISH_IS_BARE.has(country)) { target = '/' + country + (m[3] || '/'); } else { target = '/' + country + '-en' + (m[3] || '/'); } dbg('forceEnglish: redirecting off non-English language suffix', location.pathname, { to: target }); location.replace(location.origin + target + location.search + location.hash); return; } } } } // The /intl-xx/ URL check and account-setting flip used to run right // here, immediately, at document-start - before Spotify's own SPA // had even started hydrating. A user reported open.spotify.com // getting permanently stuck never finishing its initial load with // the script enabled; disabling the script, letting the page load // fully, manually setting the account language to English, then // re-enabling fixed it for them - consistent with this code's own // location.replace() call racing Spotify's startup rather than a // logic bug in the correction itself. See // runIntlCorrectionOnceReady() below for the fix: it now waits for // the player UI to actually exist before doing any of this. runIntlCorrectionOnceReady(); } // Guards so a MutationObserver storm (or multiple forceEnglish() calls, // if that ever happens) can't spawn overlapping waits or run the // correction more than once. let intlCorrectionRun = false; /** * runIntlCorrectionOnceReady - Waits for * [data-testid="control-button-playpause"] to exist in the DOM - the * persistent player-bar's play/pause button, present as soon as * Spotify's app shell has mounted, even before anything is playing, and * not localized (data-testid is an internal test hook, unlike its * aria-label) - then runs the /intl-xx/ URL correction and * account-setting flip exactly once. This is what forceEnglish() used to * do immediately at document-start; see the comment above its call to * this function for why that was moved here instead. */ function runIntlCorrectionOnceReady() { if (intlCorrectionRun) return; const run = () => { if (intlCorrectionRun) return; intlCorrectionRun = true; const m2 = location.pathname.match(/^\/intl-([a-z]{2})(\/.*)?$/i); // Some account languages (e.g. Italian) get server-redirected to an // /intl-xx/ URL on every load; others (e.g. Slovenian) never do. The // real cause, when it happens, is the saved account-level language // setting (forceEnglishAccountSetting() below) - so rather than // stripping the URL as its own navigation and only falling through to // the account fix on a later load, the stripped target path is handed // straight to forceEnglishAccountSetting(). If it needs to flip the // setting, its own verification redirect lands directly on that // stripped path - one navigation does both jobs instead of two. If the // setting turns out to already be English (or the flip can't be // attempted at all), forceEnglishAccountSetting() falls back to // stripping the URL itself so this load still isn't left on // /intl-xx/. const onIntlPrefix = m2 && m2[1].toLowerCase() !== 'en'; const strippedTarget = onIntlPrefix ? (m2[2] || '/') : null; if (HOST_IS_OPEN) { forceEnglishAccountSetting(strippedTarget); } else if (onIntlPrefix) { // Can't flip the account setting cross-origin from here, so the // cheap URL strip is the only correction available. dbg('forceEnglish: redirecting off /intl-xx/ prefix', location.pathname, { to: strippedTarget }); location.replace(location.origin + strippedTarget + location.search + location.hash); } else { dbg('forceEnglish: skipping account-setting flip', 'forceEnglishAccountSetting()', { reason: 'not open.spotify.com - iframe to open.spotify.com/preferences would be cross-origin and always fail here' }); } }; if (document.querySelector('[data-testid="control-button-playpause"]')) { run(); return; } const observer = new MutationObserver(() => { if (document.querySelector('[data-testid="control-button-playpause"]')) { observer.disconnect(); run(); } }); const startObserving = () => observer.observe(document.body, { childList: true, subtree: true }); if (document.body) { startObserving(); } else { // document-start -
hasn't been parsed yet. document.addEventListener('DOMContentLoaded', startObserving, { once: true }); } } /** * forceEnglishAccountSetting - Flip the account-level language preference * (open.spotify.com/preferences,