Greasy Fork is available in English.

Spotify кроссфейд

Настоящий кроссфейд с наложением треков через синхронизированный теневой плеер.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

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