Last active
August 25, 2026 01:45
-
-
Save addavriance/489ce29f008fd15e8f3ec9aeaa09cb69 to your computer and use it in GitHub Desktop.
Adds crossfade to Spotify tracks! (hopefully)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Spotify Crossfade | |
| // @namespace https://gist.github.com/addavriance/489ce29f008fd15e8f3ec9aeaa09cb69 | |
| // @version 1.0.0 | |
| // @description Overlapping crossfade between tracks. DEPENDS on window.SpotifyPlayer being exposed by the base "Spotify Ad Skipper" script - install/enable that one too. | |
| // @author addavriance | |
| // @match https://open.spotify.com/* | |
| // @grant none | |
| // @run-at document-idle | |
| // @updateURL https://gist.github.com/addavriance/489ce29f008fd15e8f3ec9aeaa09cb69/raw/spotify-crossfade.user.js | |
| // @downloadURL https://gist.github.com/addavriance/489ce29f008fd15e8f3ec9aeaa09cb69/raw/spotify-crossfade.user.js | |
| // ==/UserScript== | |
| (function () { | |
| const script = document.createElement('script'); | |
| script.textContent = `(${function () { | |
| const DEV = false; | |
| const log = (...a) => DEV && console.log('[SpotifyCrossfade]', ...a); | |
| const warn = (...a) => DEV && console.warn('[SpotifyCrossfade]', ...a); | |
| // tune timings | |
| const LEAD_SEC = 4; // recommended < 5 | |
| const SYNC_SEC = 1; | |
| const FADE_SEC = LEAD_SEC - SYNC_SEC; | |
| const CROSSFADE_MIN_TRACK_SEC = 20; | |
| const DEBUG_MONITOR = false; | |
| const SETTLE_MS = 300; | |
| const WAIT_TOTAL_MS = 10000; | |
| const WAIT_STEP_MS = 300; | |
| const WATCHER_INTERVAL_MS = 150; | |
| let crossfadeEnabled = true; | |
| const sleep = (ms) => new Promise(r => setTimeout(r, ms)); | |
| const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej('timeout'), ms))]); | |
| function waitForBasePlayer() { | |
| return new Promise((resolve, reject) => { | |
| let waited = 0; | |
| const check = () => { | |
| const sp = window.SpotifyPlayer; | |
| if (sp && typeof sp._lp === 'function' && typeof sp.isAd === 'function') { | |
| resolve(sp); | |
| return; | |
| } | |
| waited += WAIT_STEP_MS; | |
| if (waited >= WAIT_TOTAL_MS) { | |
| reject(new Error('window.SpotifyPlayer not found after ' + WAIT_TOTAL_MS + 'ms')); | |
| return; | |
| } | |
| setTimeout(check, WAIT_STEP_MS); | |
| }; | |
| check(); | |
| }); | |
| } | |
| waitForBasePlayer().then(initCrossfade).catch((err) => { | |
| console.error('[SpotifyCrossfade]', err.message); | |
| alert('Spotify Crossfade: window.SpotifyPlayer not found.\n\n' + | |
| 'Make sure the base script "Spotify Ad Skipper + Seek Controls" is installed and enabled ' + | |
| '- crossfade fully depends on it and cannot work without it.'); | |
| }); | |
| function initCrossfade(sp) { | |
| log('base SpotifyPlayer found, initializing crossfade'); | |
| let TrackPlayerClass = null; | |
| const loadArgsByInstance = new WeakMap(); | |
| function patchTrackPlayerClassOnce(tp) { | |
| if (TrackPlayerClass) return; | |
| TrackPlayerClass = Object.getPrototypeOf(tp).constructor; | |
| const origTpLoad = TrackPlayerClass.prototype.load; | |
| TrackPlayerClass.prototype.load = function (t, e, n) { | |
| loadArgsByInstance.set(this, { t, e, n }); | |
| return origTpLoad.call(this, t, e, n); | |
| }; | |
| log('TrackPlayerClass patched'); | |
| } | |
| let shadowTp = null; | |
| let cycleInProgress = false; | |
| function createShadowPlayer(templateTp) { | |
| const options = { | |
| transport: templateTp._transport, | |
| tracker: templateTp._tracker, | |
| audioResolver: templateTp._audioResolver, | |
| videoResolver: templateTp._videoResolver, | |
| createPlayer: templateTp._createPlayer, | |
| cubicVolume: templateTp._cubicVolume, | |
| clearBufferOnSeek: templateTp._clearBufferOnSeek, | |
| disableCache: templateTp._disableCache, | |
| licenseURLResolver: templateTp._licenseURLResolver, | |
| codecPriorities: templateTp._codecPriorities, | |
| newBufferPerTrack: true, | |
| newElementPerTrack: true, | |
| trackCacheSize: 2, | |
| }; | |
| return TrackPlayerClass.create(options); | |
| } | |
| function cleanupShadow() { | |
| if (shadowTp) { | |
| try { shadowTp.pause(); } catch {} | |
| try { shadowTp.stop({}, 'audio'); } catch {} | |
| } | |
| shadowTp = null; | |
| cycleInProgress = false; | |
| log('crossfade cycle finished, shadow cleaned up'); | |
| } | |
| function rampVolume(applyFn, peakVolume, durationMs, rising, cubic) { | |
| return new Promise(resolve => { | |
| const start = performance.now(); | |
| function step() { | |
| const raw = Math.min(1, (performance.now() - start) / durationMs); | |
| const shape = rising ? Math.sin(raw * Math.PI / 2) : Math.cos(raw * Math.PI / 2); | |
| const shaped = cubic ? Math.cbrt(shape) : shape; | |
| try { applyFn(Math.max(0.0001, peakVolume * shaped)); } catch {} | |
| if (raw < 1) { | |
| requestAnimationFrame(step); | |
| } else { | |
| try { applyFn(rising ? peakVolume : 0.0001); } catch {} | |
| resolve(); | |
| } | |
| } | |
| requestAnimationFrame(step); | |
| }); | |
| } | |
| async function spawnAndSyncShadow(activeTp, currentArgs) { | |
| const el = activeTp._player; | |
| const elapsedMs = el.currentTime * 1000; | |
| const shadow = await createShadowPlayer(activeTp); | |
| shadowTp = shadow; | |
| const shadowOptions = Object.assign({}, currentArgs.e, { | |
| position: elapsedMs, | |
| autoplay: true, | |
| muted: true, | |
| }); | |
| await shadow.load(currentArgs.t, shadowOptions); | |
| shadow.setVolume(0.0001); | |
| try { | |
| const realGain = activeTp._audioProcessor?._gainNode?.gain?.value; | |
| const shadowGainNode = shadow._audioProcessor?._gainNode; | |
| if (typeof realGain === 'number' && shadowGainNode) { | |
| shadowGainNode.gain.value = realGain; | |
| log('copied audioGain from real to shadow:', realGain); | |
| } | |
| } catch (e) { | |
| warn('audioGain copy failed:', e); | |
| } | |
| if (shadow._audioProcessor && typeof shadow._audioProcessor.resume === 'function') { | |
| try { await shadow._audioProcessor.resume(); } catch (e) { warn('shadow audioProcessor resume failed:', e); } | |
| } | |
| log('shadow spawned at', (elapsedMs / 1000).toFixed(2), 's, syncing for', SYNC_SEC, 's'); | |
| const syncStart = performance.now(); | |
| while (performance.now() - syncStart < SYNC_SEC * 1000) { | |
| if (!shadowTp) return null; | |
| const timeLeftInSync = SYNC_SEC * 1000 - (performance.now() - syncStart); | |
| if (timeLeftInSync > SETTLE_MS) { | |
| const drift = shadow._player.currentTime - el.currentTime; | |
| if (Math.abs(drift) > 0.05) { | |
| shadow._player.currentTime = el.currentTime; | |
| } | |
| } | |
| await sleep(50); | |
| } | |
| return shadow; | |
| } | |
| async function performSwap(sp, activeTp, shadow, t0, mark) { | |
| const shadowEl = shadow._player; | |
| let monitor = null; | |
| const monitorLog = []; | |
| if (DEBUG_MONITOR) { | |
| const el = activeTp._player; | |
| monitor = setInterval(() => { | |
| monitorLog.push({ | |
| t: (performance.now() - t0).toFixed(0), | |
| real_paused: el.paused, real_rs: el.readyState, real_ct: el.currentTime.toFixed(2), real_vol: el.volume.toFixed(3), | |
| shadow_paused: shadowEl.paused, shadow_rs: shadowEl.readyState, shadow_ct: shadowEl.currentTime.toFixed(2), shadow_vol: shadowEl.volume.toFixed(3), | |
| }); | |
| }, 20); | |
| } | |
| const swapVolume = activeTp.getVolume(); | |
| mark('before shadow unmute, swapVolume=' + swapVolume.toFixed(3)); | |
| shadow.setMuted(false); | |
| shadow.setVolume(swapVolume); | |
| activeTp.setVolume(0.0001); | |
| mark('after shadow unmute + real volume down, before next()'); | |
| const lp = sp._lp(); | |
| await lp.next('trackdone').catch(e => warn('native next() error', e)); | |
| mark('lp.next() resolved'); | |
| if (activeTp._audioProcessor && typeof activeTp._audioProcessor.resume === 'function') { | |
| try { await activeTp._audioProcessor.resume(); } catch (e) {} | |
| } | |
| mark('audioProcessor resumed (or skipped)'); | |
| if (DEBUG_MONITOR) { | |
| await sleep(300); | |
| clearInterval(monitor); | |
| console.table(monitorLog); | |
| mark('monitor stopped, table printed above ^'); | |
| } | |
| return swapVolume; | |
| } | |
| async function fadeCrossover(activeTp, shadow, swapVolume) { | |
| const cubic = !!activeTp._cubicVolume; | |
| const shadowEl = shadow._player; | |
| const shadowRemainingSec = Math.max(0, shadowEl.duration - shadowEl.currentTime); | |
| const safeFadeSec = Math.max(0.3, Math.min(FADE_SEC, shadowRemainingSec - 0.15)); | |
| if (safeFadeSec < FADE_SEC - 0.05) { | |
| warn('fade duration capped: only ' + shadowRemainingSec.toFixed(2) + | |
| 's left in shadow tail, using ' + safeFadeSec.toFixed(2) + 's fade instead of ' + FADE_SEC + 's'); | |
| } | |
| await Promise.all([ | |
| rampVolume((v) => activeTp.setVolume(v), swapVolume, safeFadeSec * 1000, true, cubic), | |
| rampVolume((v) => shadow.setVolume(v), swapVolume, safeFadeSec * 1000, false, cubic), | |
| ]); | |
| } | |
| async function runCrossfadeCycle(activeTp) { | |
| cycleInProgress = true; | |
| try { | |
| const currentArgs = loadArgsByInstance.get(activeTp); | |
| if (!currentArgs) { warn('no load args for current track yet, skip this cycle'); cycleInProgress = false; return; } | |
| const shadow = await spawnAndSyncShadow(activeTp, currentArgs); | |
| if (!shadow) return; // cancelled mid-sync | |
| const t0 = performance.now(); | |
| const mark = (label) => log('[T+' + (performance.now() - t0).toFixed(0) + 'ms] ' + label); | |
| const swapVolume = await performSwap(sp, activeTp, shadow, t0, mark); | |
| await fadeCrossover(activeTp, shadow, swapVolume); | |
| cleanupShadow(); | |
| } catch (err) { | |
| warn('crossfade cycle failed:', err); | |
| cleanupShadow(); | |
| } | |
| } | |
| function startCrossfadeWatcher() { | |
| setInterval(async () => { | |
| if (!crossfadeEnabled || cycleInProgress) return; | |
| const lp = sp._lp(); | |
| if (!lp) return; | |
| let activeTp; | |
| try { activeTp = await withTimeout(lp._getTrackPlayer(), 200); } catch { return; } | |
| if (!activeTp || !activeTp._player) return; | |
| patchTrackPlayerClassOnce(activeTp); | |
| const elx = activeTp._player; | |
| if (!elx.duration || isNaN(elx.duration)) return; | |
| if (elx.duration < CROSSFADE_MIN_TRACK_SEC) return; | |
| if (sp.isAd()) return; | |
| const remaining = elx.duration - elx.currentTime; | |
| if (remaining <= LEAD_SEC && remaining > 0.3) { | |
| runCrossfadeCycle(activeTp); | |
| } | |
| }, WATCHER_INTERVAL_MS); | |
| } | |
| sp.enableCrossfade = function (v = true) { | |
| crossfadeEnabled = v; | |
| log('crossfade', v ? 'enabled' : 'disabled'); | |
| }; | |
| startCrossfadeWatcher(); | |
| log('crossfade watcher started - enableCrossfade(bool) available on window.SpotifyPlayer'); | |
| } | |
| }})();`; | |
| document.documentElement.appendChild(script); | |
| script.remove(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment