Overlapping crossfade between tracks via a synced shadow player.
// ==UserScript==
// @name Spotify Crossfade
// @name:ru Spotify кроссфейд
// @description Overlapping crossfade between tracks via a synced shadow player.
// @description:ru Настоящий кроссфейд с наложением треков через синхронизированный теневой плеер.
// @keywords spotify crossfade, spotify overlap tracks, spotify smooth transition, spotify gapless, spotify кроссфейд, спотифай кроссфейд, плавный переход треков
// @namespace https://gist.github.com/addavriance/489ce29f008fd15e8f3ec9aeaa09cb69
// @author addavriance
// @license MIT
// @version 1.0.0
// @match https://open.spotify.com/*
// @grant none
// @run-at document-idle
// ==/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();
})();