Spotify Crossfade

Overlapping crossfade between tracks via a synced shadow player.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();