Spotify Crossfade

Overlapping crossfade between tracks via a synced shadow player.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Userscripts installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey installieren.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

(Ich habe bereits einen Benutzerstil Verwaltung, ich möchte ihn installieren!)

// ==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();
})();