// ==UserScript== // @name Spotifuck Mobile Stable // @namespace https://github.com/Myst1cX/spotifuck-userscript // @version 7.11 // @description Full Spotifuck 1.6.4 UI hack (with minor tweaks) + playback control + force English UI + visual premium spoof // @author Myst1cX (adapted from Spotifuck app) // @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/spotifuck-userscript // @supportURL https://github.com/Myst1cX/spotifuck-userscript/issues // @updateURL https://raw.githubusercontent.com/Myst1cX/spotifuck-userscript/main/spotifuck-mobile.user.js // @downloadURL https://raw.githubusercontent.com/Myst1cX/spotifuck-userscript/main/spotifuck-mobile.user.js // ==/UserScript== /* * Spotifuck v6 - Accurate port from reverse-engineered v1.6.4 APK * Based on r0/e.java from classes1.dex * * Features from APK: * - Library button toggle (expand 100%×100% / collapse 48×48px) * - Pure black AMOLED mode for playback controls * NOTE: currently works without needing !important on the * .encore-dark-theme custom properties, because `.YourLibraryX{ * background:var(--background-elevated-base)!important}` in the * bottom-nav/library-overlay block (Sixth big change) independently pins * the library/sidebar surface. If AMOLED ever starts showing grey again * (e.g. that rule/class gets removed or renamed in a future change, or * Spotify adds a new panel that redeclares these vars closer to its own * root than .encore-dark-theme sits), see the comment at the AMOLED * style block (find it by searching: 'AMOLED pure black mode (from r0/e.java)') * - Auto-close library on playlist selection (and load the playlist) * - UI improvements (sidebar, search bar, playback controls) * - CSS hacks for better mobile experience * * Fixed from APK: * - Library folder navigation (original behavior auto-closed library on any item selection, including folders. * * Newly added (v6.3): * - Browser-side equivalent of Spotifuck's ForceEn that forces Android app locale to English before loading its WebView * - (Forces English on open.spotify.com: overrides navigator.language/languages, * and strips a non-English /intl-xx/ locale prefix from the URL if present.) * - The feature is a functional dependency because of the following buttons hardcoded to English aria-label text: * const libBtn = document.querySelector('#Desktop_LeftSidebar_Id header button[aria-label*="Your Library"]:not(.fuckd)'); * if (libBtn.getAttribute('aria-label') === 'Collapse Your Library') { * Newly added (v6.4) - Fixed "Force English" (v6.3 was not working at all) * - forceEnglish() actually forces English now. The v6.3 version only overrode * navigator.language and stripped the /intl-xx/ URL prefix, both of which only * affect a single page load - the aria-labels Spotify actually renders (e.g. * "Open Your Library") are driven by the account-level language preference at * open.spotify.com/preferences, which is saved server-side. forceEnglish() now * also flips that setting to "en" once, via a hidden iframe so it doesn't * disrupt whatever the user is looking at, then reloads the page so the change * actually takes effect. A localStorage flag means this only runs once ever, and skips the reload * entirely if the account was already set to English. * * Newly added (v6.4.fix) - Fixed "Force English" again (v6.4 had some bug cases) * - Fixed a case where, if a user landed directly on /preferences (rather than * via the hidden iframe), the code that watches for the language ) * to "en" (the "English (English)" option - the base/US-flavored English; * "en-GB" is a separate option and NOT what this targets). * navigator.language and the /intl-xx/ URL prefix above only affect this one * page load - the aria-labels Spotify actually renders (e.g. "Open Your * Library") are driven by this account setting, which is saved server-side. * Because the user can change this setting manually at any time, this * re-checks the current value on every real page load rather than trusting * a one-time flag - the check itself is cheap (one hidden-iframe load) when * the setting is already English, and only triggers a flip + reload when * it's actually wrong. A flip is verified on the following load before * being treated as done, with a capped number of retries if it didn't * stick server-side. */ function forceEnglishAccountSetting(stripTarget = null) { // NOTE: there used to be a permanent "spotifuckForcedEnglishAccountSetting" // flag here that, once set, skipped this function forever. That assumed // the account setting only ever changes via this script. It doesn't - // the user can change it manually (e.g. via /preferences directly), and // a permanent flag would then never notice and never re-apply English. // So this now re-checks the actual setting on every real page load // instead of trusting a one-time flag. The "already English" case is // cheap (one iframe load, no reload triggered), so this is fine to run // every time; only an actual mismatch triggers the flip+reload below. // Set right before we dispatch the change event and reload - tells the // *next* load to verify the setting actually saved instead of blindly // dispatching again. const PENDING_KEY = 'spotifuckEnglishFlipPending'; // Caps how many times we'll retry a flip that doesn't stick within one // correction cycle, so a broken selector can't cause endless reloads. const ATTEMPTS_KEY = 'spotifuckEnglishFlipAttempts'; const MAX_ATTEMPTS = 3; // When called from an /intl-xx/ URL, stripTarget is the path we'd land // on with that prefix removed. Any navigation this function makes below // (the verify-reload after a flip, or the fallback strip when no flip // was possible) goes to this URL instead of a bare reload, so the // /intl-xx/ correction and the account-setting fix collapse into a // single navigation instead of two. const stripUrl = stripTarget ? (location.origin + stripTarget + location.search + location.hash) : null; const navigateAfter = (cleanup) => { cleanup(); if (stripUrl) location.replace(stripUrl); else location.reload(); }; if (window.top !== window.self) return; // only the top frame drives this const verifying = localStorage.getItem(PENDING_KEY) === 'true'; if (verifying) localStorage.removeItem(PENDING_KEY); // Runs `callback(doc, cleanup)` against the /preferences document, // either the current page (if we're already there) or a hidden iframe. // `cleanup()` removes the iframe if one was created; call it once done. const withPreferencesDoc = (callback) => { let settled = false; const fire = (doc, cleanup) => { if (settled) return; // guards against load/error/timeout all racing to call this settled = true; callback(doc, cleanup); }; if (location.pathname.startsWith('/preferences')) { fire(document, () => {}); return; } // Same-origin (open.spotify.com -> open.spotify.com), so // contentDocument access is allowed. const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = 'https://open.spotify.com/preferences'; (document.documentElement || document.body).appendChild(iframe); let removed = false; const cleanup = () => { if (removed) return; removed = true; iframe.remove(); }; iframe.addEventListener('load', () => { try { fire(iframe.contentDocument, cleanup); } catch (e) { dbg('forceEnglishAccountSetting: could not access preferences iframe', 'iframe.contentDocument', { error: String(e) }); cleanup(); fire(null, cleanup); } }); // Safety net in case the select never appears (layout change, slow load, etc.) setTimeout(() => { cleanup(); fire(null, cleanup); }, 15000); }; const giveUp = (reason) => { // Just stops this correction cycle's automatic retries - no permanent // flag is set, so the next real page load will simply check again. dbg('forceEnglishAccountSetting: giving up', '(language flip retry)', { reason }); }; const attemptFlip = () => { withPreferencesDoc((doc, cleanup) => { if (!doc) { if (stripUrl) { dbg('forceEnglish: redirecting off /intl-xx/ prefix', location.pathname, { to: stripTarget, reason: 'account flip unavailable - could not load preferences document' }); navigateAfter(cleanup); } else { cleanup(); giveUp('could not load preferences document'); } return; } applyEnglishToLanguageSelect(doc, (result) => { if (!result.found) { if (stripUrl) { dbg('forceEnglish: redirecting off /intl-xx/ prefix', location.pathname, { to: stripTarget, reason: 'account flip unavailable - language selector not found' }); navigateAfter(cleanup); } else { cleanup(); giveUp('language selector not found - Spotify may have changed the settings page'); } return; } if (!result.changed) { localStorage.removeItem(ATTEMPTS_KEY); dbg('forceEnglishAccountSetting: language already English', '#desktop.settings.selectLanguage', { reload: false }); if (stripUrl) { dbg('forceEnglish: redirecting off /intl-xx/ prefix', location.pathname, { to: stripTarget }); navigateAfter(cleanup); } else { cleanup(); } return; } // Dispatched the change event, but that only proves React // saw it - not that Spotify's backend actually saved it. // Navigate and verify on the next load before trusting this - // to the stripped URL if we have one, otherwise a plain reload. localStorage.setItem(PENDING_KEY, 'true'); dbg('forceEnglishAccountSetting: dispatched change, navigating to verify', '#desktop.settings.selectLanguage', { to: stripTarget || location.pathname }); setTimeout(() => navigateAfter(cleanup), 1000); }); }); }; if (!verifying) { attemptFlip(); return; } // Verification pass: re-read (never re-dispatch blindly) the setting // to confirm the flip from last load actually persisted. withPreferencesDoc((doc, cleanup) => { if (!doc) { cleanup(); giveUp('could not reload preferences document to verify'); return; } applyEnglishToLanguageSelect(doc, (result) => { cleanup(); if (result.found && result.value === 'en') { localStorage.removeItem(ATTEMPTS_KEY); dbg('forceEnglishAccountSetting: verified language is English', '#desktop.settings.selectLanguage', {}); return; } if (!result.found) { giveUp('language selector not found during verification - Spotify may have changed the settings page'); return; } const attempts = parseInt(localStorage.getItem(ATTEMPTS_KEY) || '0', 10) + 1; if (attempts >= MAX_ATTEMPTS) { giveUp('English flip did not stick after ' + attempts + ' attempt(s) - clear localStorage "' + ATTEMPTS_KEY + '" to retry'); return; } localStorage.setItem(ATTEMPTS_KEY, String(attempts)); dbg('forceEnglishAccountSetting: flip did not stick, retrying', '#desktop.settings.selectLanguage', { attempts, max: MAX_ATTEMPTS }); attemptFlip(); }, { readOnly: true }); }); } /** * applyEnglishToLanguageSelect - Read or set the given document's language * was located at all * - value: its current value ('en' on success), or null if not found * - changed: true only if this call just dispatched a change (write mode) * @param {Object} [options] * @param {boolean} [options.readOnly=false] - never modify the select, just report its value */ function applyEnglishToLanguageSelect(doc, onDone, { readOnly = false } = {}) { let settled = false; const resolve = (result) => { if (settled) return; // guards against double-fire (mutation callback racing the timeout) settled = true; onDone(result); }; const trySelect = () => { const select = doc.getElementById('desktop.settings.selectLanguage'); if (!select) return false; if (readOnly || select.value === 'en') { resolve({ found: true, value: select.value, changed: false }); return true; } const win = doc.defaultView || window; const nativeSetter = Object.getOwnPropertyDescriptor(win.HTMLSelectElement.prototype, 'value').set; nativeSetter.call(select, 'en'); select.dispatchEvent(new Event('change', { bubbles: true })); dbg('applyEnglishToLanguageSelect: dispatched change event', '#desktop.settings.selectLanguage', {}); resolve({ found: true, value: 'en', changed: true }); return true; }; if (trySelect()) return; const win = doc.defaultView || window; const startObserving = () => { if (trySelect()) return; // may have appeared while we were waiting for const observer = new win.MutationObserver(() => { if (trySelect()) observer.disconnect(); }); observer.observe(doc.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); resolve({ found: false, value: null, changed: false }); // timed out - selector genuinely missing }, 12000); }; if (doc.body) { // Normal case: iframe 'load' event already guarantees exists. startObserving(); } else { // document-start on the /preferences route itself, reached by // direct navigation - hasn't been parsed yet. Previously // this silently skipped setting up the observer entirely and just // timed out doing nothing. Wait for DOMContentLoaded instead. doc.addEventListener('DOMContentLoaded', startObserving, { once: true }); } } forceEnglish(); // Note: Class name ".fuckd" used throughout is from original APK source (r0/e.java) // It marks elements as "already processed" to prevent duplicate event handlers /** * switchLs - Toggle library sidebar between expanded (fullscreen overlay) and collapsed * (Spotify's own native narrow layout) states. * From r0/e.java line 202: window.switchLs=function(){...} * * Plain-language: this is the function that opens or closes the big "Your Library" * screen. It looks at whether the library is currently open (tracked on the sidebar * element itself, see isExpanded below) and does the opposite. * * @param {string} source - debug-only: which caller invoked this (see dbg() calls at each call site) */ window.switchLs = function(source = 'unknown') { // Cancel a still-pending delayed auto-collapse (see setupLibraryGrid below). // Without this, quickly reopening the library after clicking a playlist // could get immediately re-collapsed by that stale queued timeout firing // after this call - the "glitches even more" symptom. if (pendingLibCollapse !== null) { clearTimeout(pendingLibCollapse); pendingLibCollapse = null; dbg('switchLs: cancelled pending auto-collapse', '#Desktop_LeftSidebar_Id', { source }); } const leftSidebar = document.querySelector('#Desktop_LeftSidebar_Id'); if (!leftSidebar) { dbg('switchLs: ABORTED - #Desktop_LeftSidebar_Id not found', '#Desktop_LeftSidebar_Id', { source }); return; } const navFirstChild = leftSidebar.querySelector('nav>div>div:first-child'); if (!navFirstChild) { dbg('switchLs: ABORTED - nav>div>div:first-child not found', 'nav>div>div:first-child', { source }); return; } // NOTE: We used to infer state from `navFirstChild.classList.length === 2` // (ported from the APK's DOM). On the desktop web player, Spotify's own // re-renders change how many classes this wrapper has independent of // whether the library is actually expanded/collapsed, which caused the // toggle to occasionally flip the wrong way (icon/header desyncing from // the real panel size - rapid Expanded/Collapsed flicker). // Instead, track our own state on the sidebar element - we're the only // code that ever calls switchLs(), so this can never desync. const isExpanded = leftSidebar.dataset.fuckExpanded === 'true'; const libBtnNow = document.querySelector('#Desktop_LeftSidebar_Id header button[aria-label*="Your Library"]'); dbg('switchLs: called', '#Desktop_LeftSidebar_Id', { source, 'dataset.fuckExpanded (before)': leftSidebar.dataset.fuckExpanded ?? '(unset)', 'computed isExpanded': isExpanded, 'real libBtn aria-label right now': libBtnNow ? libBtnNow.getAttribute('aria-label') : '(libBtn not found)', willTakeBranch: isExpanded ? 'COLLAPSE' : 'EXPAND' }); if (!isExpanded) { // Expand to full-screen overlay console.log('#Library: Expanded'); leftSidebar.dataset.fuckExpanded = 'true'; leftSidebar.style.position = 'fixed'; leftSidebar.style.width = '100%'; leftSidebar.style.height = '100%'; leftSidebar.style.left = '0'; leftSidebar.style.top = '0'; leftSidebar.style.zIndex = '20'; const headerH1 = leftSidebar.querySelector('header>div>div:first-child h1'); if (headerH1) { const prevText = headerH1.textContent; // Using textContent for security, then manually adding close icon headerH1.textContent = '✖ \u00A0 Close Library'; dbg('switchLs: view manipulated (EXPAND)', 'header>div>div:first-child h1', { source, 'headerH1.textContent before': prevText, 'headerH1.textContent after': headerH1.textContent, 'sidebar style set': 'position:fixed; width:100%; height:100%; left:0; top:0; z-index:20' }); } else { dbg('switchLs: view manipulated (EXPAND) - header h1 NOT FOUND, icon not updated', 'header>div>div:first-child h1', { source }); } // v6.7: persist across SPA nav + sync bottom nav's active tab highlight. sessionStorage.setItem(LIB_OPEN_KEY, 'true'); if (typeof updateActiveTab === 'function') updateActiveTab(); } else { // COLLAPSE branch. // // Plain-language version: this runs whenever the library should go from the // big "Your Library" screen back to the small state. We used to do this by // squeezing the library box down to 48x48 pixels ourselves with CSS - but // Spotify's own page never found out the library was supposed to be closed, // so all its buttons and the playlist list stayed fully drawn and just got // squashed into that tiny box (that was the "glitched icon" bug). The real // fix is to let Spotify collapse itself the normal way (a real click already // does this - same idea used for auto-close-after-picking-a-playlist in // v6.7), and here we just clean up after our own fullscreen overlay so it // doesn't stay stuck on top of Spotify's now-properly-collapsed page. // // Dev version: this branch is reached by every collapse, manual or automatic // (switchLs(source) always lands here whenever isExpanded is true - see the // willTakeBranch calculation above). We don't touch Spotify's own layout at all // here; we only clear the inline styles the EXPAND branch above set // (position/width/height/left/top/zIndex), since Spotify's real collapsed // layout should render underneath once our forced overlay is gone. grid-autoclose // (v6.7) additionally fires a real click on the native toggle before this ever // runs, so Spotify's own collapse logic has already run by the time we get here. console.log('#Library: Collapsed'); leftSidebar.dataset.fuckExpanded = 'false'; leftSidebar.style.position = ''; leftSidebar.style.width = ''; leftSidebar.style.height = ''; leftSidebar.style.left = ''; leftSidebar.style.top = ''; leftSidebar.style.zIndex = ''; dbg('switchLs: view manipulated (COLLAPSE)', '#Desktop_LeftSidebar_Id', { source, note: 'cleared fullscreen-overlay inline styles (position/width/height/left/top/zIndex) - native collapsed layout shows through underneath (v6.7)' }); // v6.7: clear persistence + sync bottom nav's active tab highlight. sessionStorage.removeItem(LIB_OPEN_KEY); if (typeof updateActiveTab === 'function') updateActiveTab(); } }; /** * closeNowPlay - Close the now-playing right panel if open * From r0/e.java line 200: window.closeNowPlay=function(){...} */ window.closeNowPlay = function(source = 'unknown') { userOpenedNPV = false; // NPV guard: any close (any source) disarms the "user opened it" flag const panelContainer = document.querySelector('#Desktop_PanelContainer_Id'); if (!panelContainer) { dbg('closeNowPlay: no-op - #Desktop_PanelContainer_Id not found', '#Desktop_PanelContainer_Id', { source }); return; } const ariaHidden = panelContainer.parentNode.parentNode.ariaHidden; if (ariaHidden === 'false') { console.log('#Close NowPlaying'); const toggleBtn = panelContainer.parentNode.parentNode.nextElementSibling?.querySelector('button'); dbg('closeNowPlay: view manipulated', '#Desktop_PanelContainer_Id parent parent nextElementSibling button', { source, 'panel ariaHidden (before)': ariaHidden, action: toggleBtn ? 'clicked the toggle button to close the panel' : 'toggle button NOT FOUND - could not close', 'toggleBtn aria-label': toggleBtn ? toggleBtn.getAttribute('aria-label') : null }); if (toggleBtn) toggleBtn.click(); } else { dbg('closeNowPlay: no-op - panel already hidden', '#Desktop_PanelContainer_Id', { source, ariaHidden }); } }; /** * isNpvOpen - Whether the Now Playing view panel is currently visible. */ function isNpvOpen() { const panelContainer = document.querySelector('#Desktop_PanelContainer_Id'); if (!panelContainer) return false; if (panelContainer.parentNode.parentNode.ariaHidden !== 'false') return false; // #Desktop_PanelContainer_Id is shared by NPV, Queue, and (possibly) // Connect to a Device - all three flip the same ariaHidden flag, so // checking that alone can't tell them apart. Confirmed against live // markup: the