// WKWebView (iOS / Mac Catalyst) does not provide requestIdleCallback, so calling it
// throws "Can't find variable: requestIdleCallback" and breaks init(). Polyfill it with a
// setTimeout-based shim that mimics the IdleDeadline contract closely enough for our use.
window.requestIdleCallback = window.requestIdleCallback || function (cb, opts) {
    const start = Date.now();
    return setTimeout(() => cb({
        didTimeout: false,
        timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
    }), (opts && opts.timeout) ? Math.min(opts.timeout, 1) : 1);
};
window.cancelIdleCallback = window.cancelIdleCallback || function (id) { clearTimeout(id); };

window.nuages = {
    getScreenInfo: () => ({
        width: window.innerWidth,
        effectiveType: navigator.connection?.effectiveType ?? "unknown"
    }),

    getElementRect(id) {
        const el = document.getElementById(id);
        if (!el) return null;
        const r = el.getBoundingClientRect();
        return {
            left: r.left, top: r.top, right: r.right, bottom: r.bottom,
            width: r.width, height: r.height
        };
    },

    // Places an already-rendered (but still invisible) context menu and returns the
    // final viewport coordinates for it. Measuring the real element is the whole
    // point: the menu's size depends on how many items it has, so any size guessed
    // ahead of time is wrong for most menus and lets them hang off the edge.
    //
    //   x, y        anchor point — menu's top-left, or top-RIGHT when alignRight
    //   flipY       y to put the menu's BOTTOM at when it can't fit below the
    //               anchor (for a button trigger that's the button's top edge, so
    //               the flipped menu sits above it instead of covering it)
    //
    // Preferred side first, flip if it doesn't fit, then hard-clamp — a flip alone
    // still overflows when the menu is bigger than the space on either side.
    placeContextMenu(id, x, y, flipY, alignRight) {
        const el = document.getElementById(id);
        const margin = 8;
        if (!el) return { left: x, top: y };

        const vw = window.innerWidth;
        const vh = window.innerHeight;
        const w = el.offsetWidth;
        const h = el.offsetHeight;

        // The player bar and mobile nav are fixed over the bottom of the window, so
        // the usable area stops above them — clamping to innerHeight alone drops the
        // last items of a bottom-row menu behind that chrome.
        const bottom = ['.player-bar', '.bottom-nav'].reduce((acc, sel) => {
            const bar = document.querySelector(sel);
            if (!bar) return acc;
            const r = bar.getBoundingClientRect();
            // Only chrome actually pinned to the bottom edge counts (hidden nav
            // measures as a zero-height rect and is ignored).
            return r.height > 0 && r.bottom >= vh - 1 ? Math.max(acc, vh - r.top) : acc;
        }, 0);

        const minY = margin;
        const maxY = vh - bottom - margin;

        let left = alignRight ? x - w : x;
        if (left + w > vw - margin) left = x - w;   // doesn't fit → flip to the other side
        if (left < margin)          left = x;

        let top = y;
        if (top + h > maxY) top = flipY - h;        // doesn't fit below → flip above

        return {
            left: Math.max(margin, Math.min(left, vw - w - margin)),
            // max() last so an over-tall menu is pinned to the top and scrolls
            // internally rather than being pushed off the top edge.
            top:  Math.max(minY, Math.min(top, maxY - h))
        };
    },

    // ── Online / offline detection ────────────────────────────────────────────
    // Uses browser events so DevTools "Offline" simulation is detected correctly.
    // Connectivity.Current (.NET MAUI) only reads OS network state and does NOT
    // respond to DevTools throttling.

    onlineStatus: {
        _ref: null,
        _onOnline: null,
        _onOffline: null,

        init(dotnetRef) {
            this._ref = dotnetRef;
            this._onOnline  = () => this._ref?.invokeMethodAsync('OnOnline');
            this._onOffline = () => this._ref?.invokeMethodAsync('OnOffline');
            window.addEventListener('online',  this._onOnline);
            window.addEventListener('offline', this._onOffline);
        },

        isOnline() { return navigator.onLine; },

        dispose() {
            if (this._onOnline)  window.removeEventListener('online',  this._onOnline);
            if (this._onOffline) window.removeEventListener('offline', this._onOffline);
            this._ref = null;
        }
    },

    isSafari() {
        const ua = navigator.userAgent;
        return /Safari/.test(ua) && !/Chrome|Chromium|Edg/.test(ua);
    },

    // ── Playback capability probe ────────────────────────────────────────────
    // What can this WebView actually play? Codecs via canPlayType, streaming via
    // MSE / native HLS, DRM via requestMediaKeySystemAccess (a real handshake —
    // "supported" means the CDM is present, not just that the API exists).
    async getPlaybackCapabilities() {
        const audio = document.createElement('audio');
        const can = t => audio.canPlayType(t) !== '';

        const drm = async (keySystem) => {
            if (!navigator.requestMediaKeySystemAccess) return false;
            try {
                await navigator.requestMediaKeySystemAccess(keySystem, [{
                    initDataTypes: ['cenc'],
                    audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }]
                }]);
                return true;
            } catch { return false; }
        };

        const [widevine, playready, fairplay] = await Promise.all([
            drm('com.widevine.alpha'),
            drm('com.microsoft.playready.recommendation'),
            drm('com.apple.fps')
        ]);

        return {
            aac:       can('audio/mp4; codecs="mp4a.40.2"'),
            mp3:       can('audio/mpeg'),
            opus:      can('audio/ogg; codecs="opus"') || can('audio/webm; codecs="opus"'),
            flac:      can('audio/flac'),
            mse:       'MediaSource' in window,
            nativeHls: can('application/vnd.apple.mpegurl'),
            widevine, playready, fairplay
        };
    },

    // ── Widevine security level + Web Audio tap probe ────────────────────────
    // Answers the one question that decides whether EQ/visualizer can work on DRM
    // tracks: does this device decrypt in hardware, and does any audio actually reach
    // our Web Audio graph? On a hardware-backed (L1) path the decrypted PCM never
    // enters userspace, so createMediaElementSource yields silence and the analyser
    // reads all zeros while audio is plainly audible. Guessing this from the platform
    // is unreliable -- Android devices vary per model, and some report L1 for video
    // but fall back to software for audio-only -- so it is measured instead.
    async probeDrmAudioPath() {
        const contentType = 'audio/mp4; codecs="mp4a.40.2"';

        // Widevine maps its L1/L3 levels onto EME "robustness" strings. Probing each
        // individually is the only portable way to read the level from the web layer;
        // there is no API that returns "L1" directly.
        const robustnessLevels = [
            'SW_SECURE_CRYPTO', 'SW_SECURE_DECODE',
            'HW_SECURE_CRYPTO', 'HW_SECURE_DECODE', 'HW_SECURE_ALL'
        ];

        const supportsRobustness = async (robustness) => {
            if (!navigator.requestMediaKeySystemAccess) return false;
            try {
                await navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
                    initDataTypes: ['cenc'],
                    audioCapabilities: [{ contentType, robustness }]
                }]);
                return true;
            } catch { return false; }
        };

        const supported = [];
        for (const r of robustnessLevels)
            if (await supportsRobustness(r)) supported.push(r);

        const hardware = supported.some(r => r.startsWith('HW_'));
        const software = supported.some(r => r.startsWith('SW_'));

        // Sample the LIVE analyser rather than building a throwaway graph: the question
        // is whether the graph the EQ and visualizer actually use is receiving audio,
        // and that is only answerable while something is playing through it.
        const p = this.player;
        const audio = p?._audio;
        const analyser = p?._analyser;
        const ctxState = p?._audioCtx?.state ?? 'none';
        const playing = !!audio && !audio.paused && !audio.ended && audio.readyState >= 2;

        let tapPeak = 0;
        let tapSampled = false;
        if (analyser && ctxState === 'running' && playing) {
            // Average a few frames: a single frame can legitimately land on a near-silent
            // moment and read zero even on a perfectly working tap.
            const data = new Uint8Array(analyser.frequencyBinCount);
            for (let i = 0; i < 8; i++) {
                analyser.getByteFrequencyData(data);
                for (let j = 0; j < data.length; j++)
                    if (data[j] > tapPeak) tapPeak = data[j];
                await new Promise(r => setTimeout(r, 25));
            }
            tapSampled = true;
        }

        return {
            widevineSupported: supported.length > 0,
            robustness: supported.join(', '),
            // L1 = hardware-backed decrypt/decode; L3 = software only. Reported as the
            // inferred level plus the raw robustness list so the inference is auditable.
            securityLevel: !supported.length ? 'none' : hardware ? 'L1 (hardware)' : 'L3 (software)',
            hardwareDecrypt: hardware,
            softwareDecrypt: software,
            audioContextState: ctxState,
            isPlaying: playing,
            // Null when nothing was playing - "not measured", which is NOT the same as
            // "measured and silent". The caller must not treat these alike.
            tapSampled,
            tapPeak: tapSampled ? tapPeak : null,
            tapWorks: tapSampled ? tapPeak > 0 : null
        };
    },

    // ── Track-detail waveform ────────────────────────────────────────────────
    // Paints playback progress onto the waveform and opens a comment's bubble as
    // playback reaches it. Driven from the player's existing rAF tick and writing
    // straight to the DOM, for the same reason the transport clock does: a Blazor
    // re-render per frame would be far too expensive for a progress indicator.
    waveform: {
        _wrap: null,
        _clip: null,
        _head: null,
        _markers: [],
        _lastPct: -1,
        _lastLive: null,

        // How long a comment's bubble stays open once playback passes it.
        _liveSeconds: 4,

        _bind(wrap) {
            this._wrap = wrap;
            this._lastPct = -1;
            this._clip = document.getElementById('nuages-wf-clip-rect');
            this._head = document.getElementById('nuages-wf-head');
            this._markers = Array.from(wrap.querySelectorAll('.waveform-marker'))
                .map(el => ({ el, at: parseFloat(el.dataset.at) }))
                .filter(m => !isNaN(m.at));
            this._lastPct = -1;
            this._lastLive = null;
        },

        /// Called by the track page once its comments have rendered, since new markers
        /// appear inside the same wrapper element and so cannot be detected by identity.
        refresh() {
            const wrap = document.getElementById('track-detail-waveform');
            if (wrap) this._bind(wrap);
        },

        update(t, d) {
            const wrap = document.getElementById('track-detail-waveform');
            // Not on the track page — the overwhelmingly common case, so bail first.
            if (!wrap) { this._wrap = null; return; }
            if (wrap !== this._wrap) this._bind(wrap);

            // Not the track being played: show the waveform plain -- no played portion, no
            // playhead, no bubbles opening on time. _updateVisuals reports whatever is
            // playing, which is not necessarily what this page is showing.
            if (wrap.dataset.live !== '1') {
                if (this._lastPct !== 0) {
                    this._lastPct = 0;
                    if (this._clip) this._clip.setAttribute('width', '0');
                    if (this._head) this._head.setAttribute('x', '-10');
                }
                if (this._lastLive) {
                    this._lastLive.el.classList.remove('waveform-marker--live');
                    this._lastLive = null;
                }
                return;
            }

            if (!(d > 0)) return;

            const frac = Math.max(0, Math.min(1, t / d));

            // Round before comparing: without this every frame writes an attribute even
            // when the visible position has not moved.
            const pct = Math.round(frac * 1000) / 10;
            if (pct !== this._lastPct) {
                this._lastPct = pct;
                const x = frac * 1000;              // viewBox units
                if (this._clip) this._clip.setAttribute('width', x.toFixed(1));
                if (this._head) this._head.setAttribute('x', Math.max(0, x - 1.5).toFixed(1));
            }

            // Open the bubble for whichever comment playback has most recently passed.
            let live = null;
            for (const m of this._markers) {
                if (t >= m.at && t - m.at <= this._liveSeconds &&
                    (live === null || m.at > live.at)) live = m;
            }
            if (live !== this._lastLive) {
                if (this._lastLive) this._lastLive.el.classList.remove('waveform-marker--live');
                if (live) live.el.classList.add('waveform-marker--live');
                this._lastLive = live;
            }
        }
    },

    // ── Theme palette ────────────────────────────────────────────────────────
    // Pushes user-selected colours to :root as CSS custom properties. The
    // bass-reactive gradients in TopSearchBar/MainLayout are layered on top
    // of these, so the theme becomes visible whenever playback is idle.
    theme: {
        apply(sidebarTop, sidebarBottom, bgTop, bgBottom) {
            const r = document.documentElement.style;
            r.setProperty('--theme-sidebar-top',    sidebarTop);
            r.setProperty('--theme-sidebar-bottom', sidebarBottom);
            r.setProperty('--theme-bg-top',         bgTop);
            r.setProperty('--theme-bg-bottom',      bgBottom);
        },

        // BetterDiscord-style user CSS: a single <style> element in <head> that the
        // user controls from Settings. Replacing its text content live-restyles the app.
        applyCustomCss(css) {
            let el = document.getElementById('nuages-custom-css');
            if (!el) {
                el = document.createElement('style');
                el.id = 'nuages-custom-css';
                document.head.appendChild(el);
            }
            el.textContent = css || '';
        }
    },

    // Bass-reactive config driven by ThemeService. Each theme provides a hue
    // base and range so the pulse colour fits the palette; intensityMult lets
    // the user dial the overall strength of the effect. Additional params
    // control centroid-based hue modulation and master on/off.
    bass: {
        _hueBase: 240,
        _hueRange: 80,
        _intensityMult: 1.0,
        _reactiveEnabled: true,
        _hueModEnabled: true,
        _centroidRange: 0.3,
        _centroidSmoothing: 0.85,
        _frameRate: 24,
        configure(hueBase, hueRange, intensityMult,
                  reactiveEnabled, hueModEnabled, centroidRange, centroidSmoothing,
                  frameRate) {
            this._hueBase = Number(hueBase) || 0;
            this._hueRange = Number(hueRange) || 0;
            this._intensityMult = Math.max(0, Math.min(2, Number(intensityMult) || 0));
            if (reactiveEnabled !== undefined)
                this._reactiveEnabled = !!reactiveEnabled;
            if (hueModEnabled !== undefined)
                this._hueModEnabled = !!hueModEnabled;
            if (centroidRange !== undefined)
                this._centroidRange = Math.max(0.1, Math.min(4.0, Number(centroidRange) || 0.3));
            if (centroidSmoothing !== undefined)
                this._centroidSmoothing = Math.max(0.5, Math.min(0.95, Number(centroidSmoothing) || 0.85));
            if (frameRate !== undefined)
                this._frameRate = Math.max(12, Math.min(60, Number(frameRate) || 24));
        }
    },

    // ── Carousel arrows ───────────────────────────────────────────────────────
    // Scrolls the .feed--row inside the arrow's .carousel by ~one viewport width.
    carousel: {
        scroll(btn, dir) {
            const row = btn?.parentElement?.querySelector('.feed--row');
            if (row) row.scrollBy({ left: dir * row.clientWidth * 0.85, behavior: 'smooth' });
        }
    },

    // ── Blob URL helpers (cached file playback on Mac / iOS / Android) ────────

    blobFromBase64(base64, mimeType) {
        const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
        const blob = new Blob([bytes], { type: mimeType });
        return URL.createObjectURL(blob);
    },

    revokeUrl(url) {
        if (url && url.startsWith('blob:')) {
            try { URL.revokeObjectURL(url); } catch {}
        }
    },

    lyrics: {
        _lines: null,
        _activeIdx: -1,
        _autoScroll: true,
        _programmatic: false,
        _scrollTimer: null,
        _tickBound: null,
        // The element _tickBound is attached to. NOT interchangeable with player._audio:
        // that is an alias for the active deck and moves on a crossfade, so unbinding via
        // it would leave the listener on whatever element was current at dispose time.
        _boundAudio: null,
        // Container + its listeners, so a re-init doesn't stack another copy of each.
        _scrollEl: null,
        _onScroll: null,
        _onScrollEnd: null,
        _progTimer: null,

        init(lines) {
            this.dispose();
            this._lines = lines;   // [{time: N, id: "lyric-0"}, ...] sorted, time pre-scaled by C#
            this._activeIdx = -1;
            this._autoScroll = true;
            this._didCenter = false;

            const audio = window.nuages?.player?._audio;
            if (!audio) return;
            // Reads through _boundAudio rather than closing over `audio`, so rebindAudio()
            // can move the listener to a new deck without rebuilding the handler.
            this._tickBound = () => this._tick(this._boundAudio?.currentTime ?? 0);
            this._boundAudio = audio;
            audio.addEventListener('timeupdate', this._tickBound);

            const container = document.querySelector('.lyrics-scroll-area');
            if (container) {
                this._scrollEl = container;
                this._onScroll = () => {
                    if (this._programmatic) return;
                    this._autoScroll = false;
                    clearTimeout(this._scrollTimer);
                    this._scrollTimer = setTimeout(() => { this._autoScroll = true; }, 4000);
                };
                // Ends the programmatic window when the smooth scroll actually finishes.
                // See _tick: a smooth scroll emits scroll events for hundreds of
                // milliseconds, far past the single rAF this used to wait.
                this._onScrollEnd = () => this._endProgrammatic();
                container.addEventListener('scroll', this._onScroll, { passive: true });
                container.addEventListener('scrollend', this._onScrollEnd, { passive: true });
            }

            this._tick(audio.currentTime);
        },

        dispose() {
            if (this._boundAudio && this._tickBound)
                this._boundAudio.removeEventListener('timeupdate', this._tickBound);
            if (this._scrollEl) {
                if (this._onScroll)    this._scrollEl.removeEventListener('scroll', this._onScroll);
                if (this._onScrollEnd) this._scrollEl.removeEventListener('scrollend', this._onScrollEnd);
            }
            clearTimeout(this._progTimer);
            this._boundAudio = null;
            this._scrollEl = null;
            this._onScroll = null;
            this._onScrollEnd = null;
            this._programmatic = false;
            this._tickBound = null;
            this._lines = null;
            this._activeIdx = -1;
        },

        /**
         * Follows the active deck. player._audio is an alias that crossfade repoints at the
         * other deck mid-track; the listener stays on the element it was added to, so
         * without this the highlight would track the outgoing deck and then freeze when
         * that deck is torn down. Called from player._adoptDeck.
         */
        rebindAudio() {
            const audio = window.nuages?.player?._audio;
            if (!audio || !this._tickBound || !this._lines?.length) return;
            if (audio === this._boundAudio) return;
            this._boundAudio?.removeEventListener('timeupdate', this._tickBound);
            this._boundAudio = audio;
            audio.addEventListener('timeupdate', this._tickBound);
            this._tick(audio.currentTime);
        },

        _endProgrammatic() {
            clearTimeout(this._progTimer);
            this._progTimer = null;
            this._programmatic = false;
        },

        // Externally driven position (seconds) — used for native FairPlay playback, where the
        // WebView <audio> is silent so its 'timeupdate' never fires. No-ops if no lyrics loaded.
        setTime(pos) { if (this._lines?.length) this._tick(pos); },

        // Sync offset (seconds, user-adjustable): added to the playback position before
        // matching lines. Positive = highlight earlier (anticipate). A small default covers
        // position-tick granularity + output latency; the lyrics page lets the user fine-tune.
        _lead: 0.55,
        _lastPos: 0,

        // Live sync adjustment from the lyrics page. Re-evaluates immediately at the last
        // known position so the change is visible without waiting for the next tick.
        setOffset(sec, pos) {
            this._lead = sec;
            this._activeIdx = -2;            // force re-highlight on the re-tick
            const p = (typeof pos === 'number') ? pos : this._lastPos;
            if (this._lines?.length) this._tick(p);
        },

        _tick(pos) {
            if (!this._lines?.length) return;
            this._lastPos = pos;   // raw position, so setOffset can re-evaluate here
            pos += this._lead;
            let lo = 0, hi = this._lines.length - 1, idx = -1;
            while (lo <= hi) {
                const mid = (lo + hi) >> 1;
                if (this._lines[mid].time <= pos) { idx = mid; lo = mid + 1; }
                else hi = mid - 1;
            }
            const changed = idx !== this._activeIdx;
            if (changed) {
                this._activeIdx = idx;
                for (let i = 0; i < this._lines.length; i++) {
                    const el = document.getElementById(this._lines[i].id);
                    if (!el) continue;
                    const dist = i - idx;
                    if (dist >= -5 && dist <= 3)
                        el.dataset.dist = String(dist);
                    else
                        delete el.dataset.dist;
                }
            }

            // Centre the active line — or, before the first line is reached (idx<0), centre
            // line 0 — and do it on the very first tick too so the opening lines aren't pinned
            // low by the top padding while waiting for playback to start.
            if (this._autoScroll && (changed || !this._didCenter)) {
                this._didCenter = true;
                const el = document.getElementById(this._lines[idx >= 0 ? idx : 0].id);
                const container = document.querySelector('.lyrics-scroll-area');
                if (el && container) {
                    // Use the visual (post-transform) bounding rects so the scaled-up
                    // active line is centered, not just its layout box.
                    const containerRect = container.getBoundingClientRect();
                    const elRect = el.getBoundingClientRect();
                    const elVisualCenter = (elRect.top + elRect.bottom) / 2;
                    // Centre slightly ABOVE the geometric middle (~0.40 vs 0.50) so the active
                    // line sits about one line higher — the true centre read as too low.
                    const target = container.scrollTop +
                        (elVisualCenter - containerRect.top) -
                        container.clientHeight * 0.40;
                    // Hold the flag until the scroll actually ends. behavior:'smooth' keeps
                    // emitting scroll events for hundreds of milliseconds, and clearing after
                    // a single rAF meant our own centring was read as the user scrolling —
                    // which switched _autoScroll off, so the lyrics highlighted but stopped
                    // following along. 'scrollend' clears it; the timeout is the fallback for
                    // engines that don't fire it, and for a scroll that lands on no movement.
                    this._programmatic = true;
                    clearTimeout(this._progTimer);
                    this._progTimer = setTimeout(() => this._endProgrammatic(), 900);
                    container.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
                }
            }
        },
    },

    player: {
        _audio: null,
        _ref: null,
        _canPlayHandler: null,
        _hls: null,
        _currentBlobUrl: null,

        // rAF loop (driven by the unified scheduler — see _rafAdd / _rafRemove)
        _userSeeking: false,

        // Throttle for C# callbacks
        _lastTimeSent: -Infinity,

        // Lag detection
        _loadStartTime: null,
        _isFirstPlay: true,
        _stallStart: null,
        _lastSeekTime: -Infinity,
        _lastLagNotified: -Infinity,
        _lastImageNotified: -Infinity,

        // ── Web Audio API / EQ / Visualizer ──────────────────────────────────
        _audioCtx: null,
        _source: null,
        _eqFilters: null,
        _hpfNode: null,        // BiquadFilter — highpass (low cut)
        _lpfNode: null,        // BiquadFilter — lowpass  (high cut)
        _preGainNode: null,    // GainNode — user-controlled pre-EQ gain
        _compGainNode: null,   // GainNode — auto gain compensation (silent)
        _limiterNode: null,    // DynamicsCompressor — brick-wall limiter
        _volumeNode: null,     // GainNode — master volume (replaces audio.volume after Web Audio is wired)
        _analyser: null,
        _eqOpen: false,
        _eqCanvases: new Set(), // active canvas IDs; supports mini + big mode simultaneously
        _vizDataLogged: false,

        // ── Init ──────────────────────────────────────────────────────────────

        // -- Decks -------------------------------------------------------------
        // Crossfading needs two tracks audible at once, and createMediaElementSource can
        // only ever be called once per <audio> element -- so each deck owns its element,
        // its source node and its own gain, and both gains feed the shared EQ chain.
        //
        // this._audio / _hls / _source / _currentBlobUrl are ALIASES for the active deck.
        // The rest of this file keeps using them unchanged; only _swapDeck moves them.
        // Crossfade is off by default, and while it is off the second deck is never
        // created, so the single-deck path behaves exactly as it did before.
        _decks: null,
        _deckIndex: 0,
        _crossfadeSeconds: 0,
        _fadeTimer: null,        // pending teardown of the outgoing deck, cancellable
        _pendingFade: null,      // fade awaiting proof the incoming deck actually plays

        init(dotnetRef) {
            this._ref = dotnetRef;
            if (this._audio) return;

            this._decks = [this._makeDeck()];
            this._deckIndex = 0;
            this._adoptDeck(this._decks[0]);

            // Defer non-critical setup so init() returns faster -- image observers
            // don't need to block the first render.
            requestIdleCallback(() => {
                this._setupImageObserver();
                this._setupImageFallback();
            }, { timeout: 2000 });
        },

        _makeDeck() {
            const audio = new Audio();
            audio.playsInline = true;
            // crossOrigin is set per-track in loadAndPlay:
            //   "anonymous" for http(s) proxy URLs (CORS headers present on server)
            //   null        for blob: URLs (same-origin, CORS would break loading)
            const deck = { audio, source: null, gain: null, hls: null, blobUrl: null };
            this._attachDeckListeners(deck);
            return deck;
        },

        /** Points the active-deck aliases at `deck`. */
        _adoptDeck(deck) {
            this._audio          = deck.audio;
            this._source         = deck.source;
            this._hls            = deck.hls;
            this._currentBlobUrl = deck.blobUrl;
            // The lyric engine listens on the element itself, not on this alias, so it has
            // to be moved across too — otherwise it keeps following the outgoing deck.
            window.nuages?.lyrics?.rebindAudio?.();
        },

        /** Writes the active-deck aliases back into `deck` before switching away. */
        _stashDeck(deck) {
            deck.source  = this._source;
            deck.hls     = this._hls;
            deck.blobUrl = this._currentBlobUrl;
        },

        _attachDeckListeners(deck) {
            const el = deck.audio;
            // A deck that is no longer active must not drive anything: during a crossfade
            // the outgoing deck keeps playing and will fire timeupdate, pause and ended,
            // none of which should move the queue or repaint the transport.
            const isActive = () => el === this._audio;

            el.addEventListener('playing', () => {
                if (!isActive()) return;
                const now = performance.now();
                const ctxState = this._audioCtx?.state ?? 'none';
                console.log(`[nuages] playing -- ctx=${ctxState} src=${el.src?.substring(0,60)}`);

                // Ensure the AudioContext is running whenever audio actually plays.
                // On WebKit the context may still be suspended if the initial resume()
                // call happened outside a synchronous user-gesture frame.
                if (this._audioCtx && this._audioCtx.state !== 'running') {
                    console.warn(`[nuages] ctx suspended at play-time (${ctxState}), resuming`);
                    this._audioCtx.resume().catch(e => console.warn('[nuages] resume failed:', e));
                }

                if (this._isFirstPlay && this._loadStartTime !== null) {
                    const elapsedMs = now - this._loadStartTime;
                    this._isFirstPlay = false;
                    if (elapsedMs > 4000) this._notifyAudioLag();
                }

                if (this._stallStart !== null) {
                    const stallMs = now - this._stallStart;
                    this._stallStart = null;
                    if (stallMs > 3000 && now - this._lastSeekTime > 3000)
                        this._notifyAudioLag();
                }

                this._startRaf();
                this._startBassLoop();
                this._ref?.invokeMethodAsync('OnPlaybackChanged', true);
            });

            el.addEventListener('pause', () => {
                if (!isActive()) return;
                this._stopRaf();
                this._stallStart = null;
                if (!this._nativeTransportActive) {
                    const d = isNaN(el.duration) ? 0 : el.duration;
                    this._updateVisuals(el.currentTime, d);
                }
                this._stopBassLoop();
                this._ref?.invokeMethodAsync('OnPlaybackChanged', false);
            });

            el.addEventListener('ended', () => {
                if (!isActive()) return;
                this._stopRaf();
                this._stallStart = null;
                this._stopBassLoop();
                this._ref?.invokeMethodAsync('OnEnded');
            });

            el.addEventListener('waiting', () => {
                if (isActive()) this._stallStart = performance.now();
            });

            el.addEventListener('seeking', () => {
                if (!isActive()) return;
                this._lastSeekTime = performance.now();
                this._stallStart = null;
            });

            el.addEventListener('timeupdate', () => {
                if (!isActive()) return;
                const d = isNaN(el.duration) ? 0 : el.duration;
                const t = el.currentTime;
                // Only paint from timeupdate when the rAF progress consumer isn't
                // already driving it (e.g. while paused/seeking). Never for the Linux
                // Widevine relay (_nativeTransportActive) -- see _startRaf's comment.
                if (!this._rafConsumers?.has('progress') && !this._nativeTransportActive)
                    this._updateVisuals(t, d);
                const now = performance.now();
                if (now - this._lastTimeSent > 1000 && !this._nativeTransportActive) {
                    this._lastTimeSent = now;
                    this._ref?.invokeMethodAsync('OnTimeUpdate', t, d);
                }
            });

            el.addEventListener('progress', () => { if (isActive()) this._updateBuffer(); });

            // Not gated: an error on either deck is worth reporting.
            el.addEventListener('error', () => {
                const err = el.error;
                const msg = `Audio error code=${err?.code} msg=${err?.message} src=${el.src?.substring(0, 80)}`;
                console.error('[nuages]', msg);
                try { DotNet.invokeMethodAsync('Nuages', 'ReceiveWebLog', 'error', msg); } catch {}
            });
        },

        // ── Global image fallback (replaces broken CDN images with placeholder) ─

        _setupImageFallback() {
            document.addEventListener('error', function (e) {
                const img = e.target;
                if (img.tagName !== 'IMG') return;
                if (img._nuagesFallback) return; // already replaced once
                const src = img.getAttribute('src') ?? '';
                if (!src || src.startsWith('images/')) return; // already a local asset
                img._nuagesFallback = true;
                // data-fallback="none": the caller has its own art behind this image, so
                // hide the broken one and let that show rather than swapping in a person.
                if (img.dataset.fallback === 'none') { img.style.display = 'none'; return; }
                img.src = 'images/placeholder_avatar.png';
            }, true /* capture — fires before any bubbling handler */);
        },

        // ── Web Audio graph (lazy — requires prior user gesture) ──────────────

        // Creates AudioContext + nodes synchronously (must be in user-gesture context).
        // Does NOT call createMediaElementSource yet — that is deferred to _connectSource()
        // which is called from canplay, after the browser has resolved CORS for the URL.
        // On WebKit: if createMediaElementSource is called before CORS headers are received,
        // the source node is created but permanently outputs silence (max=0 in analyser).
        initAudioGraph() {
            if (this._audioCtx || !this._audio) {
                console.log(`[nuages] initAudioGraph: skip (ctx=${this._audioCtx?.state ?? 'none'} audio=${!!this._audio})`);
                return;
            }
            try {
                this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
                console.log(`[nuages] initAudioGraph: ctx created state=${this._audioCtx.state}`);

                // 10-band peaking EQ: 32 · 64 · 125 · 250 · 500 · 1k · 2k · 4k · 8k · 16k Hz
                const freqs = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
                this._eqFilters = freqs.map(freq => {
                    const f = this._audioCtx.createBiquadFilter();
                    f.type = 'peaking';
                    f.frequency.value = freq;
                    f.Q.value = 1.4;
                    f.gain.value = 0;
                    return f;
                });

                this._analyser = this._audioCtx.createAnalyser();
                this._analyser.fftSize = 2048; // higher resolution for big mode
                this._analyser.smoothingTimeConstant = 0.8;

                // ── HPF (highpass) — 20 Hz default = audible bypass ─────────
                this._hpfNode = this._audioCtx.createBiquadFilter();
                this._hpfNode.type = 'highpass';
                this._hpfNode.frequency.value = 20;
                this._hpfNode.Q.value = 0.707; // Butterworth maximally-flat

                // ── LPF (lowpass) — 20 kHz default = audible bypass ─────────
                this._lpfNode = this._audioCtx.createBiquadFilter();
                this._lpfNode.type = 'lowpass';
                this._lpfNode.frequency.value = 20000;
                this._lpfNode.Q.value = 0.707;

                // ── Pre-gain — user-controlled GainNode before the EQ chain ──
                this._preGainNode = this._audioCtx.createGain();
                this._preGainNode.gain.value = 1.0; // 0 dB

                // ── Comp-gain — auto gain compensation (transparent to user) ──
                // When auto-comp is on, this silently reduces level by the peak
                // EQ boost so tonal shaping doesn't increase overall loudness.
                this._compGainNode = this._audioCtx.createGain();
                this._compGainNode.gain.value = 1.0; // 0 dB

                // ── Limiter — DynamicsCompressor as brick-wall limiter ────────
                // -1 dBFS ceiling with 1 dB soft knee — starts gently so it
                // sounds transparent even with heavy EQ boosts.
                this._limiterNode = this._audioCtx.createDynamicsCompressor();
                this._limiterNode.threshold.value = -1.0;  // dBFS
                this._limiterNode.knee.value      = 1;     // 1 dB soft knee
                this._limiterNode.ratio.value     = 20;    // 20:1 ≈ limiting
                this._limiterNode.attack.value    = 0.001; // 1 ms — near-instant
                this._limiterNode.release.value   = 0.05;  // 50 ms

                // Master volume — controls output after the full chain so it works
                // even after createMediaElementSource (audio.volume stops working in WebKit).
                this._volumeNode = this._audioCtx.createGain();
                this._volumeNode.gain.value = this._audio?.volume ?? 0.8;

                this._audioCtx.resume()
                    .then(() => console.log(`[nuages] initAudioGraph: resume OK state=${this._audioCtx?.state}`))
                    .catch(e => console.warn('[nuages] initAudioGraph: resume failed:', e));
                // Enable bass-reactive UI colors by default
                this._bassEnabled = true;
                console.log(`[nuages] initAudioGraph: nodes ready, source deferred to canplay`);
            } catch (e) {
                console.warn('[nuages] initAudioGraph: failed:', e);
                this._audioCtx     = null;
                this._eqFilters    = null;
                this._hpfNode      = null;
                this._lpfNode      = null;
                this._preGainNode  = null;
                this._compGainNode = null;
                this._limiterNode  = null;
                this._volumeNode   = null;
                this._analyser     = null;
            }
        },

        // Called from canplay (after CORS headers received) — safe to tap the audio element.
        _chainWired: false,

        /**
         * Wires the shared processing chain exactly once. Deck gains feed its head
         * (_hpfNode). This used to live inside _connectSource, which was correct while
         * there was only ever one source -- with two decks connecting, re-running it would
         * connect hpf->lpf a second time and sum the signal through the chain twice.
         */
        _wireChain() {
            if (this._chainWired || !this._audioCtx) return;
            // [deck gains] -> hpf -> lpf -> preGain -> compGain -> EQ[0..9] -> limiter -> analyser -> volume -> dest
            let node = this._hpfNode;
            node.connect(this._lpfNode);      node = this._lpfNode;
            node.connect(this._preGainNode);  node = this._preGainNode;
            node.connect(this._compGainNode); node = this._compGainNode;
            for (const f of this._eqFilters) { node.connect(f); node = f; }
            node.connect(this._limiterNode);  node = this._limiterNode;
            node.connect(this._analyser);     node = this._analyser;
            node.connect(this._volumeNode);
            this._volumeNode.connect(this._audioCtx.destination);
            this._chainWired = true;
        },

        /** Connects the ACTIVE deck's element into the chain through its own gain. */
        _connectSource() {
            if (this._source || !this._audioCtx || !this._audio) {
                console.log(`[nuages] _connectSource: skip (source=${!!this._source} ctx=${this._audioCtx?.state ?? 'none'})`);
                return;
            }
            try {
                this._wireChain();
                const deck = this._decks?.[this._deckIndex];

                // Per-deck gain: what a crossfade actually moves. Kept separate from
                // _volumeNode so a fade cannot fight the user's volume setting.
                if (deck && !deck.gain) {
                    deck.gain = this._audioCtx.createGain();
                    deck.gain.gain.value = 1;
                    deck.gain.connect(this._hpfNode);
                }

                // A deck swapped in by a crossfade comes up silent. Its gain only exists
                // from this point, which is why that is applied here rather than at the
                // swap. The rise itself waits until the deck is genuinely playing.
                if (deck?.startSilent) deck.gain.gain.value = 0;

                this._source = this._audioCtx.createMediaElementSource(this._audio);
                this._source.connect(deck?.gain ?? this._hpfNode);
                if (deck) deck.source = this._source;
                console.log(`[nuages] _connectSource: deck ${this._deckIndex} wired — ctx=${this._audioCtx.state} crossOrigin=${this._audio.crossOrigin}`);
            } catch (e) {
                console.warn('[nuages] _connectSource: failed:', e);
                this._source = null;
            }
        },

        _eqDirty: true,

        // Writing .value on a live AudioParam is a step discontinuity, audible as a click
        // or zipper noise while a slider is dragged — worst in applyEq, where ten bands
        // move at once. setTargetAtTime approaches the target exponentially instead; ~20ms
        // is short enough to feel immediate and long enough to remove the edge.
        //
        // Only for parameters that change DURING playback. Graph construction still assigns
        // .value directly: there is no audio to click yet, and ramping from a default would
        // be wrong. The limiter's ratio/knee are likewise left stepped — they are a mode
        // switch, not a level.
        //
        // Note the approach is asymptotic, so the param reads slightly under target for a
        // moment. Nothing reads these back — EQ state lives in EqStateService — so that is
        // invisible here.
        _ramp(param, value, seconds = 0.02) {
            const ctx = this._audioCtx;
            if (!ctx || !param) { if (param) param.value = value; return; }
            try {
                param.cancelScheduledValues(ctx.currentTime);
                param.setTargetAtTime(value, ctx.currentTime, seconds);
            } catch {
                param.value = value;   // older engines without the scheduling API
            }
        },

        setEqBand(bandIndex, gainDb) {
            if (this._eqFilters?.[bandIndex] && this._audioCtx)
                this._ramp(this._eqFilters[bandIndex].gain, gainDb);
            this._eqDirty = true;
        },

        // Applies the entire EQ state in a single interop call (10 bands + HPF/LPF/
        // pre-gain/auto-comp/limiter), instead of 17 separate awaited bridge round-trips.
        // `state` is the camelCased EqStateService.EqSnapshot.
        applyEq(state) {
            if (!state || !this._audioCtx) return;
            if (this._eqFilters && Array.isArray(state.gains)) {
                for (let i = 0; i < this._eqFilters.length && i < state.gains.length; i++)
                    this._ramp(this._eqFilters[i].gain, state.gains[i]);
            }
            this.setHpf(state.hpf);
            this.setLpf(state.lpf);
            this.setPreGain(state.preGain);
            this.setAutoGainComp(state.autoComp);
            this.setLimiterEnabled(state.limiterEnabled);
            this.setLimiterCeiling(state.limiterCeiling);
            this.setLimiterRelease(state.limiterRelease);
            this._eqDirty = true;
        },

        setHpf(freq) {
            if (this._hpfNode && this._audioCtx)
                this._ramp(this._hpfNode.frequency, Math.max(10, Math.min(freq, 20000)));
        },

        setLpf(freq) {
            if (this._lpfNode && this._audioCtx)
                this._ramp(this._lpfNode.frequency, Math.max(20, Math.min(freq, 22000)));
        },

        setPreGain(db) {
            if (this._preGainNode && this._audioCtx)
                this._ramp(this._preGainNode.gain, Math.pow(10, db / 20));
        },

        // When disabled: ratio=1:1, high threshold — passes through.
        // Threshold/release are preserved so toggling on restores the user's settings.
        setLimiterEnabled(enabled) {
            if (!this._limiterNode) return;
            if (enabled) {
                this._limiterNode.ratio.value = 20;
                this._limiterNode.knee.value  = 1;
            } else {
                this._limiterNode.threshold.value = 0;
                this._limiterNode.ratio.value     = 1;
                this._limiterNode.knee.value      = 40;
            }
        },

        setLimiterCeiling(db) {
            if (!this._limiterNode || !this._audioCtx) return;
            this._ramp(this._limiterNode.threshold, Math.min(0, Math.max(-24, db)));
        },

        setLimiterRelease(seconds) {
            if (!this._limiterNode || !this._audioCtx) return;
            this._limiterNode.release.value = Math.min(2, Math.max(0.005, seconds));
        },

        // compensationDb: 0 = off, negative value = reduce level by that amount.
        // Set to -(peak positive band gain) for transparent EQ boost without loudness increase.
        setAutoGainComp(compensationDb) {
            if (!this._compGainNode || !this._audioCtx) return;
            this._ramp(this._compGainNode.gain, Math.pow(10, compensationDb / 20));
        },

        // Returns gain reduction in dB (0 = no limiting, negative = limiting in progress).
        getSaturation() { return this._limiterNode?.reduction ?? 0; },

        // canvasId: 'eq-canvas' (mini panel) or 'eq-canvas-big' (full page).
        // Multiple canvases can be open simultaneously.
        openEq(canvasId) {
            canvasId = canvasId || 'eq-canvas';
            this._eqCanvases.add(canvasId);
            this._eqOpen = true;
            this.initAudioGraph(); // synchronous — must stay in user-gesture context
            const ctxState = this._audioCtx?.state ?? 'none';
            console.log(`[nuages] openEq(${canvasId}): ctx=${ctxState} analyser=${!!this._analyser}`);
            if (this._audioCtx && this._audioCtx.state !== 'running') {
                this._audioCtx.resume()
                    .then(() => console.log(`[nuages] openEq: resume OK state=${this._audioCtx?.state}`))
                    .catch(e => console.warn('[nuages] openEq: resume failed:', e));
            }
            // GPU: promote canvas to its own compositor layer so the rAF
            // paint doesn't invalidate the entire page layout.
            const el = document.getElementById(canvasId);
            if (el) el.style.willChange = 'transform';
            this._observeCanvas(canvasId);
            this._startViz();
        },

        closeEq(canvasId) {
            canvasId = canvasId || 'eq-canvas';
            this._eqCanvases.delete(canvasId);
            this._eqOpen = this._eqCanvases.size > 0;
            const c = document.getElementById(canvasId);
            if (c) c.getContext('2d').clearRect(0, 0, c.width, c.height);
            this._unobserveCanvas(canvasId);
            if (!this._eqOpen) this._stopViz();
        },

        // ── Canvas size cache (avoids per-frame forced reflow) ────────────────
        // A single ResizeObserver tracks the CSS size of every open viz canvas so
        // the draw loop never has to read offsetWidth/offsetHeight (a sync layout
        // flush) on each frame.
        _observeCanvas(canvasId) {
            if (!this._canvasSizes) this._canvasSizes = new Map();
            if (!this._canvasRO && typeof ResizeObserver !== 'undefined') {
                this._canvasRO = new ResizeObserver((entries) => {
                    for (const e of entries) {
                        const cr = e.contentRect;
                        if (e.target.id) this._canvasSizes.set(e.target.id, { w: cr.width, h: cr.height });
                    }
                });
            }
            const el = document.getElementById(canvasId);
            if (el) {
                // Seed the cache once, up front, so the first frame has a size
                // without forcing a layout from inside the rAF callback.
                this._canvasSizes.set(canvasId, { w: el.offsetWidth, h: el.offsetHeight });
                this._canvasRO?.observe(el);
            }
        },

        _unobserveCanvas(canvasId) {
            const el = document.getElementById(canvasId);
            if (el && this._canvasRO) this._canvasRO.unobserve(el);
            this._canvasSizes?.delete(canvasId);
        },

        // ── Unified rAF scheduler ─────────────────────────────────────────────
        // A single requestAnimationFrame loop drives every per-frame consumer
        // (playback progress, spectrum visualizer, bass-reactive colour). Each
        // consumer is gated to its own target frame-rate, so one 60 Hz reschedule
        // replaces the three independent rAF loops this used to run (~3× fewer
        // animation-frame callbacks on the main thread).
        _rafConsumers: null,
        _rafId: null,
        _rafLoop: null,

        _rafAdd(key, fn, gateFn) {
            if (!this._rafConsumers) this._rafConsumers = new Map();
            this._rafConsumers.set(key, { fn, gate: gateFn, last: 0 });
            if (this._rafId != null) return;
            if (!this._rafLoop) {
                this._rafLoop = (ts) => {
                    const cs = this._rafConsumers;
                    if (!cs || cs.size === 0) { this._rafId = null; return; }
                    for (const c of cs.values()) {
                        if (ts - c.last >= c.gate()) { c.last = ts; c.fn(ts); }
                    }
                    this._rafId = requestAnimationFrame(this._rafLoop);
                };
            }
            this._rafId = requestAnimationFrame(this._rafLoop);
        },

        _rafRemove(key) {
            if (!this._rafConsumers) return;
            this._rafConsumers.delete(key);
            if (this._rafConsumers.size === 0 && this._rafId != null) {
                cancelAnimationFrame(this._rafId);
                this._rafId = null;
            }
        },

        // Shared FFT read — called by both the visualizer and the bass-colour
        // consumer. The analyser is sampled at most once per animation frame
        // (keyed by the rAF timestamp), so the two consumers no longer each copy
        // ~1 KB out of the DSP thread on every frame.
        _readFreq(ts) {
            const a = this._analyser;
            if (!a) return null;
            const n = a.frequencyBinCount;
            if (!this._freqData || this._freqData.length !== n)
                this._freqData = new Uint8Array(n);
            if (this._freqTs !== ts) {
                a.getByteFrequencyData(this._freqData);
                this._freqTs = ts;
            }
            return this._freqData;
        },

        // ── Visualizer rAF loop ───────────────────────────────────────────────

        _startViz() {
            this._rafAdd(
                'viz',
                (ts) => { if (this._eqOpen) this._drawViz(ts); else this._rafRemove('viz'); },
                () => Math.round(1000 / (window.nuages?.bass?._frameRate ?? 24))
            );
        },

        _stopViz() {
            this._rafRemove('viz');
            for (const id of this._eqCanvases) {
                const c = document.getElementById(id);
                if (c) c.getContext('2d').clearRect(0, 0, c.width, c.height);
            }
        },

        // ── BPM detection ─────────────────────────────────────────────────────
        // Samples the low-frequency (bass) energy from the analyser for ~6 seconds,
        // detects onset peaks, then runs autocorrelation on the peak intervals to
        // find the dominant tempo. Returns a Promise<int|null> — the detected BPM
        // (60–200 range, halved/doubled to fit) or null if no stable beat was found.
        //
        // Requires the Web Audio graph to be running (the analyser must be wired),
        // so it only works for non-DRM tracks played through the WebView <audio>.
        _bpmSampling: false,
        _bpmSamples: null,
        _bpmSampleRate: 60, // Hz — how often we grab a bass-energy sample

        detectBpm() {
            return new Promise((resolve) => {
                if (!this._analyser || !this._audioCtx || this._audioCtx.state !== 'running') {
                    console.warn('[nuages] detectBpm: audio graph not running');
                    resolve(null);
                    return;
                }
                if (this._bpmSampling) {
                    console.warn('[nuages] detectBpm: already sampling');
                    resolve(null);
                    return;
                }

                const durationMs = 6000; // 6 seconds of sampling
                const interval = 1000 / this._bpmSampleRate;
                const sampleCount = Math.round(durationMs / interval);
                const samples = new Float32Array(sampleCount);
                let idx = 0;

                this._bpmSampling = true;
                this._bpmSamples = samples;

                const sr = this._audioCtx.sampleRate || 44100;
                const binRes = sr / (this._analyser.fftSize || 2048);
                const lo = Math.max(1, Math.round(20 / binRes));
                const hi = Math.max(lo, Math.min(this._analyser.frequencyBinCount - 1, Math.round(250 / binRes)));

                const grab = () => {
                    if (idx >= sampleCount) {
                        this._bpmSampling = false;
                        const bpm = this._computeBpm(samples, this._bpmSampleRate);
                        console.log(`[nuages] detectBpm: result = ${bpm ?? 'null'}`);
                        resolve(bpm);
                        return;
                    }
                    const data = new Uint8Array(this._analyser.frequencyBinCount);
                    this._analyser.getByteFrequencyData(data);
                    let sum = 0;
                    for (let i = lo; i <= hi; i++) sum += data[i];
                    samples[idx++] = sum / (hi - lo + 1) / 255;
                    setTimeout(grab, interval);
                };
                grab();
            });
        },

        // Offline BPM detection from a complete audio file (base64-encoded bytes).
        // Unlike detectBpm it needs no playback: decodeAudioData → mono downmix →
        // one-pole lowpass (~150 Hz, isolates kick/bass) → RMS envelope at 50 Hz →
        // the same _computeBpm autocorrelation. Analyzes up to 90 s starting ~25 %
        // in (skips intros), which beats the live detector's 6 s window on accuracy.
        // Returns the BPM or null (undecodable / no stable beat). DRM files and
        // exotic containers simply fail decodeAudioData and resolve to null.
        async detectBpmOffline(b64) {
            let ctx = null;
            try {
                const bin = atob(b64);
                const bytes = new Uint8Array(bin.length);
                for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);

                const Ctor = window.AudioContext || window.webkitAudioContext;
                ctx = new Ctor();
                const audio = await ctx.decodeAudioData(bytes.buffer);

                const sr  = audio.sampleRate;
                let start = Math.floor(Math.min(audio.duration * 0.25, 30) * sr);
                let len   = Math.min(Math.floor(90 * sr), audio.length - start);
                if (len < sr * 10) { start = 0; len = audio.length; } // short track — use all of it

                const ch0 = audio.getChannelData(0);
                const ch1 = audio.numberOfChannels > 1 ? audio.getChannelData(1) : null;

                const envRate = 50; // Hz — envelope sample rate fed to _computeBpm
                const hop = Math.round(sr / envRate);
                const env = new Float32Array(Math.floor(len / hop));
                const a = 1 - Math.exp(-2 * Math.PI * 150 / sr); // one-pole lowpass @150 Hz
                let lp = 0, acc = 0, n = 0, e = 0;
                for (let i = 0; i < len && e < env.length; i++) {
                    const x = ch1 ? (ch0[start + i] + ch1[start + i]) * 0.5 : ch0[start + i];
                    lp += a * (x - lp);
                    acc += lp * lp;
                    if (++n === hop) { env[e++] = Math.sqrt(acc / hop); acc = 0; n = 0; }
                }

                const bpm = this._computeBpm(env, envRate);
                console.log(`[nuages] detectBpmOffline: result = ${bpm ?? 'null'} (dur=${audio.duration.toFixed(1)}s)`);
                return bpm;
            } catch (err) {
                console.warn(`[nuages] detectBpmOffline: ${err?.message ?? err}`);
                return null;
            } finally {
                try { ctx?.close(); } catch { /* already closed */ }
            }
        },

        // Autocorrelation-based tempo extraction from a bass-energy envelope.
        // Finds the lag (in samples) with the highest correlation, converts to BPM,
        // and folds the result into the 60–200 range (halve/double as needed).
        _computeBpm(samples, sampleRate) {
            const n = samples.length;
            if (n < 30) return null;

            // Normalize: subtract mean
            let mean = 0;
            for (let i = 0; i < n; i++) mean += samples[i];
            mean /= n;
            const norm = new Float32Array(n);
            for (let i = 0; i < n; i++) norm[i] = samples[i] - mean;

            // Autocorrelation: test lags corresponding to 50–200 BPM
            // BPM = 60 * sampleRate / lag  →  lag = 60 * sampleRate / BPM
            const minBpm = 50, maxBpm = 200;
            const minLag = Math.round(60 * sampleRate / maxBpm);
            const maxLag = Math.round(60 * sampleRate / minBpm);
            let bestLag = 0, bestCorr = 0;

            for (let lag = minLag; lag <= maxLag && lag < n; lag++) {
                let corr = 0;
                for (let i = 0; i < n - lag; i++) corr += norm[i] * norm[i + lag];
                corr /= (n - lag);
                if (corr > bestCorr) { bestCorr = corr; bestLag = lag; }
            }

            if (bestLag === 0 || bestCorr < 0.01) return null;

            let bpm = Math.round(60 * sampleRate / bestLag);

            // Fold into 60–200 range
            while (bpm < 60) bpm *= 2;
            while (bpm > 200) bpm = Math.round(bpm / 2);

            return bpm;
        },

        // ── Bass-reactive color loop ──────────────────────────────────────────
        // Reads low-frequency bins (bass ~20-250 Hz) and sets CSS custom properties
        // on <html> so the sidebar/top-bar gradient can pulse with the beat.
        _bassEnabled: false, // user preference: bass-reactive colors on/off
        _bassSmoothed: 0,

        startBassReactive() {
            this._bassEnabled = true;
            this._startBassLoop();
        },

        stopBassReactive() {
            this._bassEnabled = false;
            this._stopBassLoop();
            this._clearBassVars();
        },

        _startBassLoop() {
            if (!this._bassEnabled) return;
            this._bassSmoothed = 0;
            this._rafAdd(
                'bass',
                (ts) => { if (this._bassEnabled) this._updateBassColor(ts); else this._rafRemove('bass'); },
                () => Math.round(1000 / (window.nuages?.bass?._frameRate ?? 24))
            );
        },

        _stopBassLoop() {
            this._rafRemove('bass');
        },

        _updateBassColor(ts) {
            if (!this._analyser || !this._audioCtx || this._audioCtx.state !== 'running') return;

            const cfg = window.nuages?.bass;
            // Master on/off — if disabled, clear the CSS vars and bail
            if (!cfg?._reactiveEnabled) {
                this._clearBassVars();
                return;
            }

            const data = this._readFreq(ts);
            if (!data) return;
            const bufLen = data.length;

            // ── Intensity: full sub-bass 20–60 Hz ──────────────────────────────
            // Kick fundamentals, 808s, and rumble. Average + peak-pick inside the
            // band keeps tiny kicks from being smoothed to nothing. This drives
            // --bass-intensity (luminosity/opacity of the glow) and stays "pretty
            // constant" as the user requested — it only changes with overall energy.
            const sr = this._audioCtx.sampleRate || 44100;
            const binRes = sr / (this._analyser.fftSize || 2048);
            const lo = Math.max(1, Math.round(20 / binRes));
            const hi = Math.max(lo, Math.min(bufLen - 1, Math.round(60 / binRes)));
            let sum = 0, peak = 0;
            for (let i = lo; i <= hi; i++) {
                const v = data[i];
                sum += v;
                if (v > peak) peak = v;
            }
            const mean = sum / (hi - lo + 1) / 255;
            const peakN = peak / 255;
            const avg = Math.max(mean, peakN * 0.5);

            this._bassSmoothed = this._bassSmoothed * 0.7 + avg * 0.3;
            const raw = Math.min(1, this._bassSmoothed * 1.0);
            const mult = cfg._intensityMult ?? 1.0;
            const intensity = Math.min(1, raw * mult);

            // ── Hue: spectral centroid of infrabass 20–40 Hz ───────────────────
            // Instead of sweeping hue with energy (which only changes on volume),
            // we compute the weighted average frequency (spectral centroid) within
            // the infrabass band. This makes hue follow melodic basslines — an 808
            // slide from 30→50 Hz, a walking sub-bass line, or a filter sweep all
            // shift the colour subtly. The centroid is normalised to 0..1 across
            // the band and mapped to a user-configurable fraction of the range.
            const base = cfg._hueBase ?? 240;
            const range = cfg._hueRange ?? 80;

            let hue;
            if (cfg._hueModEnabled) {
                const infraLo = Math.max(1, Math.round(20 / binRes));
                const infraHi = Math.max(infraLo, Math.min(bufLen - 1, Math.round(40 / binRes)));
                let weightedSum = 0, energySum = 0;
                for (let i = infraLo; i <= infraHi; i++) {
                    const v = data[i];
                    const freq = i * binRes;
                    weightedSum += v * freq;
                    energySum += v;
                }
                // Normalised centroid position within the infraband (0 = 20 Hz, 1 = 40 Hz)
                const centroidNorm = energySum > 0
                    ? (weightedSum / energySum - 20) / 20
                    : 0.5;
                // Smooth the centroid using user-configured factor
                const smoothFactor = cfg._centroidSmoothing ?? 0.85;
                if (this._bassCentroidSmoothed === undefined)
                    this._bassCentroidSmoothed = centroidNorm;
                this._bassCentroidSmoothed = this._bassCentroidSmoothed * smoothFactor + centroidNorm * (1 - smoothFactor);
                const centroid = Math.max(0, Math.min(1, this._bassCentroidSmoothed));

                // User-configured fraction of the palette range
                const centroidRange = cfg._centroidRange ?? 0.3;
                const hueRange = range * centroidRange;
                hue = (base - hueRange / 2 + centroid * hueRange) % 360;
            } else {
                // Hue modulation disabled — use palette base hue
                hue = base % 360;
            }
            if (hue < 0) hue += 360;

            // Quantise to a step the eye can't resolve, then skip the write when
            // the value is unchanged. Writing a custom property on <html> restyles
            // every element that references it (the sidebar/top-bar/main glow), so
            // suppressing no-op writes removes the bulk of the per-frame style
            // recalc during steady passages.
            // Quantise COARSELY. A trace showed UpdateLayoutTree at 10.6s over 5,679 calls
            // against ~5,250 animation frames — a style recalc essentially every frame,
            // dwarfing scripting (2.1s) and layout (0.5s). At two decimals the intensity
            // changes on nearly every frame during music, so the skip below almost never
            // fired. 0.05 steps and 4-degree hue steps are indistinguishable on a diffuse
            // glow but cut the number of writes, and with them the recalcs, by an order of
            // magnitude.
            const intStr = (Math.round(intensity / 0.05) * 0.05).toFixed(2);
            const hueStr = (Math.round(hue / 4) * 4).toFixed(0);

            const setInt = intStr !== this._lastBassIntensity;
            const setHue = hueStr !== this._lastBassHue;
            if (!setInt && !setHue) return;

            for (const el of this._bassTargets()) {
                if (setInt) el.style.setProperty('--bass-intensity', intStr);
                if (setHue) el.style.setProperty('--bass-hue', hueStr);
            }
            if (setInt) this._lastBassIntensity = intStr;
            if (setHue) this._lastBassHue = hueStr;
        },

        // Elements that actually read the glow variables. Written individually because the
        // properties are registered as non-inheriting (see app.css) — that is what stops a
        // write to an ancestor invalidating its whole subtree, which for <main> meant every
        // track card on the page.
        //
        // .avatar-lg is listed explicitly: a descendant selector reads the variable, and
        // without inheritance it no longer arrives from .avatar-wrap.
        //
        // Every other consumer reads the variables from a pseudo-element, which does not
        // inherit a non-inherited property from its originating element — so those rules
        // pull them in with `--bass-*: inherit` (see the .razor.css files). Writing to the
        // originating element here is only half of it; miss either half and the glow is
        // dark. .top-home-btn belongs here for that reason: .top-home-btn::after reads the
        // variables, so the home button needs the write just like the hamburger does.
        _bassTargetSelector:
            '.sidebar, main, .top-home-btn, .top-hamburger, .top-search-wrap, ' +
            '.avatar-badge-wrap, .avatar-wrap, .avatar-wrap .avatar-lg',
        _bassEls: null,
        _bassElsAt: 0,

        _bassTargets() {
            // Re-queried periodically rather than per frame: pages come and go, so the set
            // changes, but querying every frame would give back the cost this saves.
            const now = performance.now();
            if (!this._bassEls || now - this._bassElsAt > 1000) {
                this._bassEls = Array.from(document.querySelectorAll(this._bassTargetSelector));
                this._bassElsAt = now;
            }
            return this._bassEls;
        },

        // Clears the bass CSS vars and resets the no-op-skip cache so the next
        // active frame re-applies them.
        _clearBassVars() {
            // Re-query rather than reuse the cache: this runs when playback stops, and a
            // stale list would leave the glow lit on an element that has since re-rendered.
            for (const el of document.querySelectorAll(this._bassTargetSelector)) {
                el.style.removeProperty('--bass-intensity');
                el.style.removeProperty('--bass-hue');
            }
            // Legacy: earlier builds wrote these to <html>, and a value left there would
            // still inherit wherever @property is unsupported.
            document.documentElement.style.removeProperty('--bass-intensity');
            document.documentElement.style.removeProperty('--bass-hue');
            this._bassEls = null;
            this._lastBassIntensity = null;
            this._lastBassHue = null;
        },

        _drawViz(ts) {
            if (!this._analyser || !this._audioCtx) {
                if (!this._vizDataLogged) {
                    console.warn(`[nuages] _drawViz: no analyser/ctx`);
                    this._vizDataLogged = true;
                }
                return;
            }

            // Read FFT data once for this frame — shared across all canvases AND
            // the bass-colour consumer via _readFreq (keyed by the rAF timestamp).
            const data = this._readFreq(ts);
            if (!data) return;

            if (!this._vizDataLogged) {
                const max = data.reduce((a, b) => Math.max(a, b), 0);
                console.log(`[nuages] _drawViz: first frame max=${max} ctx=${this._audioCtx.state}`);
                this._vizDataLogged = true;
            }

            // Draw to every registered canvas (mini panel + big mode can coexist).
            for (const id of this._eqCanvases) this._drawVizToCanvas(id, data);
        },

        _drawVizToCanvas(canvasId, data) {
            const canvas = document.getElementById(canvasId);
            if (!canvas) return;

            const dpr = window.devicePixelRatio || 1;
            // Use the size cached by the ResizeObserver (set in openEq) instead of
            // reading offsetWidth/offsetHeight every frame — those reads flush
            // pending style + layout synchronously (forced reflow) right after the
            // bass loop has dirtied styles.
            let size = this._canvasSizes && this._canvasSizes.get(canvasId);
            if (!size) {
                size = { w: canvas.offsetWidth, h: canvas.offsetHeight };
                if (this._canvasSizes) this._canvasSizes.set(canvasId, size);
            }
            const W = size.w;
            const H = size.h;
            if (W === 0 || H === 0) return;

            if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
                canvas.width  = Math.round(W * dpr);
                canvas.height = Math.round(H * dpr);
            }

            const ctx = canvas.getContext('2d');
            ctx.save();
            ctx.scale(dpr, dpr);
            ctx.clearRect(0, 0, W, H);

            const bufLen     = data.length;
            const sampleRate = this._audioCtx?.sampleRate ?? 44100;

            // ── Shared log-scale helpers (20 Hz – 22 kHz) ────────────────────
            const LOG_MIN  = 20, LOG_MAX = 22000;
            const logRange = Math.log(LOG_MAX / LOG_MIN);
            // frequency → canvas X
            const freqToX   = f  => Math.log(f / LOG_MIN) / logRange * W;
            // frequency → nearest FFT bin
            const freqToBin = f  => Math.min(bufLen - 1, Math.round(f * bufLen * 2 / sampleRate));

            // ── Subtle frequency grid lines ───────────────────────────────────
            ctx.strokeStyle = 'rgba(255,255,255,0.05)';
            ctx.lineWidth   = 1;
            for (const f of [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]) {
                const x = freqToX(f);
                ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
            }

            // ── Log-scale spectrum ────────────────────────────────────────────
            // Each point maps to a log-spaced frequency so low-mids aren't squished.
            const numPts = 120;
            if (!this._vizPtsX || this._vizPtsX.length !== numPts) {
                this._vizPtsX = new Float32Array(numPts);
                this._vizPtsY = new Float32Array(numPts);
            }
            for (let i = 0; i < numPts; i++) {
                const freq = LOG_MIN * Math.pow(LOG_MAX / LOG_MIN, i / (numPts - 1));
                const bin  = freqToBin(freq);
                const v    = (data[bin] || 0) / 255;
                this._vizPtsX[i] = freqToX(freq);
                this._vizPtsY[i] = H - v * H * 0.85;
            }

            // Filled area
            ctx.beginPath();
            ctx.moveTo(0, H);
            ctx.lineTo(this._vizPtsX[0], this._vizPtsY[0]);
            for (let i = 0; i < numPts - 1; i++) {
                const mx = (this._vizPtsX[i] + this._vizPtsX[i + 1]) * 0.5;
                const my = (this._vizPtsY[i] + this._vizPtsY[i + 1]) * 0.5;
                ctx.quadraticCurveTo(this._vizPtsX[i], this._vizPtsY[i], mx, my);
            }
            ctx.lineTo(this._vizPtsX[numPts - 1], this._vizPtsY[numPts - 1]);
            ctx.lineTo(W, H);
            ctx.closePath();
            const waveGrad = ctx.createLinearGradient(0, 0, 0, H);
            waveGrad.addColorStop(0,   'rgba(255, 85, 0, 0.72)');
            waveGrad.addColorStop(0.6, 'rgba(255, 85, 0, 0.18)');
            waveGrad.addColorStop(1,   'rgba(255, 85, 0, 0.00)');
            ctx.fillStyle = waveGrad;
            ctx.fill();

            // Crisp stroke
            ctx.beginPath();
            ctx.moveTo(this._vizPtsX[0], this._vizPtsY[0]);
            for (let i = 0; i < numPts - 1; i++) {
                const mx = (this._vizPtsX[i] + this._vizPtsX[i + 1]) * 0.5;
                const my = (this._vizPtsY[i] + this._vizPtsY[i + 1]) * 0.5;
                ctx.quadraticCurveTo(this._vizPtsX[i], this._vizPtsY[i], mx, my);
            }
            ctx.lineTo(this._vizPtsX[numPts - 1], this._vizPtsY[numPts - 1]);
            ctx.strokeStyle = 'rgba(255, 110, 30, 0.85)';
            ctx.lineWidth   = 1.5;
            ctx.stroke();

            // ── Combined EQ + HPF + LPF response curve ────────────────────────
            const allFilters = [
                this._hpfNode, this._lpfNode,
                ...(this._eqFilters ?? [])
            ].filter(Boolean);

            if (allFilters.length) {
                const nF = 256;
                if (!this._eqFreqBuf || this._eqFreqBuf.length !== nF) {
                    this._eqFreqBuf  = new Float32Array(nF);
                    this._eqMagBuf   = new Float32Array(nF);
                    this._eqPhaseBuf = new Float32Array(nF);
                    this._eqCombBuf  = new Float32Array(nF);
                    // Same log spacing as freqToX so curves stay perfectly aligned
                    for (let i = 0; i < nF; i++)
                        this._eqFreqBuf[i] = LOG_MIN * Math.pow(LOG_MAX / LOG_MIN, i / (nF - 1));
                    this._eqDirty = true;
                }
                // Only recompute filter response when EQ settings have changed.
                if (this._eqDirty) {
                    this._eqCombBuf.fill(1.0);
                    for (const f of allFilters) {
                        f.getFrequencyResponse(this._eqFreqBuf, this._eqMagBuf, this._eqPhaseBuf);
                        for (let i = 0; i < nF; i++) this._eqCombBuf[i] *= this._eqMagBuf[i];
                    }
                    this._eqDirty = false;
                }

                const maxDb = 12;
                const zeroY = H * 0.5;

                // dB grid lines: ±6 dB
                ctx.strokeStyle = 'rgba(255,255,255,0.05)';
                ctx.lineWidth   = 1;
                for (const dbl of [-6, 6]) {
                    const y = zeroY - (dbl / maxDb) * zeroY * 0.88;
                    ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
                }

                // 0 dB reference
                ctx.beginPath();
                ctx.moveTo(0, zeroY); ctx.lineTo(W, zeroY);
                ctx.strokeStyle = 'rgba(255,255,255,0.10)';
                ctx.lineWidth   = 1;
                ctx.stroke();

                if (!this._eqCurveX || this._eqCurveX.length !== nF) {
                    this._eqCurveX = new Float32Array(nF);
                    this._eqCurveY = new Float32Array(nF);
                }
                let anyBoost = false;
                for (let i = 0; i < nF; i++) {
                    // eqFreqBuf is log-spaced with same base, so i/(nF-1)*W == freqToX(eqFreqBuf[i])
                    this._eqCurveX[i] = (i / (nF - 1)) * W;
                    const db = 20 * Math.log10(Math.max(this._eqCombBuf[i], 1e-9));
                    this._eqCurveY[i] = zeroY - (db / maxDb) * zeroY * 0.88;
                    if (Math.abs(db) > 0.05) anyBoost = true;
                }

                if (anyBoost) {
                    // Filled area
                    ctx.beginPath();
                    ctx.moveTo(this._eqCurveX[0], zeroY);
                    ctx.lineTo(this._eqCurveX[0], this._eqCurveY[0]);
                    for (let i = 0; i < nF - 1; i++) {
                        const mx = (this._eqCurveX[i] + this._eqCurveX[i + 1]) * 0.5;
                        const my = (this._eqCurveY[i] + this._eqCurveY[i + 1]) * 0.5;
                        ctx.quadraticCurveTo(this._eqCurveX[i], this._eqCurveY[i], mx, my);
                    }
                    ctx.lineTo(this._eqCurveX[nF - 1], zeroY);
                    ctx.closePath();
                    const eqFill = ctx.createLinearGradient(0, 0, 0, H);
                    eqFill.addColorStop(0,   'rgba(255,200,60,0.18)');
                    eqFill.addColorStop(0.5, 'rgba(255,200,60,0.04)');
                    eqFill.addColorStop(1,   'rgba(60,120,255,0.18)');
                    ctx.fillStyle = eqFill;
                    ctx.fill();

                    // Curve line
                    ctx.beginPath();
                    ctx.moveTo(this._eqCurveX[0], this._eqCurveY[0]);
                    for (let i = 0; i < nF - 1; i++) {
                        const mx = (this._eqCurveX[i] + this._eqCurveX[i + 1]) * 0.5;
                        const my = (this._eqCurveY[i] + this._eqCurveY[i + 1]) * 0.5;
                        ctx.quadraticCurveTo(this._eqCurveX[i], this._eqCurveY[i], mx, my);
                    }
                    ctx.lineTo(this._eqCurveX[nF - 1], this._eqCurveY[nF - 1]);
                    ctx.strokeStyle = 'rgba(255,200,60,0.80)';
                    ctx.lineWidth   = 1.5;
                    ctx.stroke();
                }

                // ── Band dots ─────────────────────────────────────────────────
                // One dot per EQ band, sitting on the EQ curve. Color = boost/cut/flat.
                const bandFreqs = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
                // Inverse of eqFreqBuf formula: fractional index for any frequency
                const freqToFI  = f => Math.max(0, Math.min(nF - 1,
                    (nF - 1) * Math.log(f / LOG_MIN) / logRange));

                for (const bFreq of bandFreqs) {
                    const fi  = freqToFI(bFreq);
                    const lo  = Math.floor(fi);
                    const hi  = Math.min(nF - 1, lo + 1);
                    const t   = fi - lo;
                    const mag = this._eqCombBuf[lo] * (1 - t) + this._eqCombBuf[hi] * t;
                    const db  = 20 * Math.log10(Math.max(mag, 1e-9));
                    const bX  = freqToX(bFreq);
                    const bY  = zeroY - (db / maxDb) * zeroY * 0.88;

                    const absDb   = Math.abs(db);
                    const isBoost = db >  0.3;
                    const isCut   = db < -0.3;

                    // Radial glow for non-flat bands
                    if (absDb > 0.3) {
                        const grd = ctx.createRadialGradient(bX, bY, 0, bX, bY, 9);
                        grd.addColorStop(0, isBoost ? 'rgba(255,120,50,0.40)' : 'rgba(100,150,255,0.40)');
                        grd.addColorStop(1, 'rgba(0,0,0,0)');
                        ctx.beginPath();
                        ctx.arc(bX, bY, 9, 0, Math.PI * 2);
                        ctx.fillStyle = grd;
                        ctx.fill();
                    }

                    // Dot
                    ctx.beginPath();
                    ctx.arc(bX, bY, absDb > 0.3 ? 3.5 : 2.5, 0, Math.PI * 2);
                    ctx.fillStyle   = isBoost ? '#ff7733' : isCut ? '#6699ff' : 'rgba(160,160,160,0.45)';
                    ctx.fill();
                    ctx.strokeStyle = 'rgba(0,0,0,0.55)';
                    ctx.lineWidth   = 0.8;
                    ctx.stroke();
                }
            }

            // ── Saturation / GR indicator ─────────────────────────────────────
            const grDb = this._limiterNode?.reduction ?? 0;
            if (grDb < -0.5) {
                const lvl   = Math.min(Math.abs(grDb) / 12, 1);
                const red   = Math.round(180 + 75 * lvl);
                const green = Math.round(120 * (1 - lvl * lvl));
                const pW = 62, pH = 15, pX = W - pW - 6, pY = 5, r = 4;
                ctx.save();
                ctx.fillStyle = `rgba(${red},${green},0,0.88)`;
                ctx.beginPath();
                ctx.moveTo(pX + r, pY);
                ctx.lineTo(pX + pW - r, pY);
                ctx.arcTo(pX + pW, pY,      pX + pW, pY + r,      r);
                ctx.lineTo(pX + pW, pY + pH - r);
                ctx.arcTo(pX + pW, pY + pH, pX + pW - r, pY + pH, r);
                ctx.lineTo(pX + r,  pY + pH);
                ctx.arcTo(pX,       pY + pH, pX, pY + pH - r,     r);
                ctx.lineTo(pX,      pY + r);
                ctx.arcTo(pX,       pY,      pX + r, pY,          r);
                ctx.closePath();
                ctx.fill();
                ctx.fillStyle = '#fff';
                ctx.font      = 'bold 8px monospace';
                ctx.textAlign    = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText(`GR ${grDb.toFixed(1)} dB`, pX + pW / 2, pY + pH / 2);
                ctx.restore();
            }

            ctx.restore();
        },

        // ── Lag notification helpers ──────────────────────────────────────────

        _notifyAudioLag() {
            const now = performance.now();
            if (now - this._lastLagNotified < 30000) return;
            this._lastLagNotified = now;
            this._ref?.invokeMethodAsync('OnLagDetected');
        },

        _notifyImageLag() {
            const now = performance.now();
            if (now - this._lastImageNotified < 30000) return;
            if (now - this._lastLagNotified < 5000) return;
            this._lastImageNotified = now;
            this._ref?.invokeMethodAsync('OnSlowImages');
        },

        // ── PerformanceObserver — slow image detection ────────────────────────

        _setupImageObserver() {
            if (typeof PerformanceObserver === 'undefined') return;
            try {
                const obs = new PerformanceObserver(list => {
                    for (const entry of list.getEntries()) {
                        if (entry.initiatorType === 'img' && entry.duration > 2500) {
                            this._notifyImageLag();
                            break;
                        }
                    }
                });
                obs.observe({ type: 'resource', buffered: true });
            } catch (e) {}
        },

        // ── Playback-progress rAF consumer ────────────────────────────────────
        // Gated to ~30 fps — the C# time callback is already throttled to 1 s.

        _startRaf() {
            this._rafAdd(
                'progress',
                () => {
                    if (!this._audio) { this._rafRemove('progress'); return; }
                    // Linux Widevine relay: this._audio IS a real, playing <audio> element
                    // (needed for the Web Audio graph / EQ / analyser — that keeps running
                    // regardless), but its own .currentTime/.duration are relay-buffering
                    // artifacts, not the track's real position — WidevinePlayer's own CDP-
                    // polled state (delivered via updateProgress) is authoritative while
                    // this flag is set. See setNativeTransport.
                    if (!this._userSeeking && !this._nativeTransportActive) {
                        const d = isNaN(this._audio.duration) ? 0 : this._audio.duration;
                        this._updateVisuals(this._audio.currentTime, d);
                    }
                },
                () => 33 // ~30 fps
            );
        },

        // Linux Widevine relay only — see _startRaf's comment.
        setNativeTransport(active) { this._nativeTransportActive = !!active; },

        _stopRaf() {
            this._rafRemove('progress');
        },

        // ── Visual update helpers ─────────────────────────────────────────────

        _fmtTime(s) {
            if (!isFinite(s) || s < 0) s = 0;
            const total = Math.floor(s);
            const sec = total % 60;
            const min = Math.floor(total / 60) % 60;
            const hr  = Math.floor(total / 3600);
            const pad = n => String(n).padStart(2, '0');
            return hr > 0 ? `${hr}:${pad(min)}:${pad(sec)}` : `${min}:${pad(sec)}`;
        },

        // ── PlayerBar visuals ─────────────────────────────────────────────────
        // These run on the rAF tick, and every write below dirties the ROOT layer: the
        // buffer halo's width is a layout, the gradient and the clock are paints. Writing
        // all four unconditionally ~65 times a second is what a trace showed as 3,402
        // full-viewport repaints and 1,653 layouts across 52.6s -- for values that change
        // at most once a second. The nodes are cached and every write is guarded.
        _pbEls: null,
        _lastKey: null,
        _lastCurS: null,
        _lastDurS: null,
        _lastBufW: null,

        _playerBarEls() {
            // Keyed on isConnected rather than a timer: Blazor re-renders replace these
            // nodes, and a stale reference would write to a detached one -- the bar would
            // simply stop moving. Re-querying clears the write cache too, so the fresh
            // nodes are painted on the very next tick instead of inheriting "unchanged".
            const els = this._pbEls;
            if (els && els.slider && els.slider.isConnected) return els;
            this._pbEls = {
                slider: document.getElementById('pb-slider'),
                cur:    document.getElementById('pb-time-cur'),
                dur:    document.getElementById('pb-time-dur'),
                halo:   document.getElementById('pb-buffer-halo'),
            };
            this._lastKey = this._lastCurS = this._lastDurS = this._lastBufW = null;
            return this._pbEls;
        },

        _updateVisuals(t, d) {
            const els = this._playerBarEls();
            const slider = els.slider;
            if (!slider) return;
            const pct = d > 0 ? Math.min(100, t / d * 100) : 0;
            // Round to 1 decimal — avoids allocating long decimal strings on every RAF tick.
            // 1000 steps across the bar is roughly one per pixel at full width, so this is
            // the visible resolution rather than an approximation of it.
            const pctS = pct.toFixed(1);
            // With no duration the percentage is pinned at 0, so fall back to the raw
            // position as the change key or the thumb would never move.
            const key = d > 0 ? pctS : t.toFixed(1);
            if (key !== this._lastKey) {
                this._lastKey = key;
                slider.value = t;
                slider.style.setProperty('--track-bg',
                    `linear-gradient(to right, #ff5500 0%, #ff5500 ${pctS}%, #2a2a2a ${pctS}%, #2a2a2a 100%)`
                );
            }
            // Update the time labels directly in the DOM so Blazor doesn't have to
            // re-render the whole PlayerBar once per second just for the clock. The string
            // only changes once a second; assigning textContent replaces the text node and
            // invalidates layout whether or not the characters differ, so compare first.
            const curS = this._fmtTime(t);
            if (els.cur && curS !== this._lastCurS) {
                this._lastCurS = curS;
                els.cur.textContent = curS;
            }
            if (els.dur && d > 0) {
                const durS = this._fmtTime(d);
                if (durS !== this._lastDurS) {
                    this._lastDurS = durS;
                    els.dur.textContent = durS;
                }
            }
            this._updateBuffer();
            // Track-detail waveform rides the same tick; it no-ops off that page.
            window.nuages.waveform.update(t, d);
        },

        _updateBuffer() {
            const audio = this._audio;
            const halo  = this._playerBarEls().halo;
            if (!halo || !audio || isNaN(audio.duration) || audio.duration <= 0) return;
            let bufferedEnd = 0;
            const b = audio.buffered;
            for (let i = 0; i < b.length; i++)
                if (b.end(i) > bufferedEnd) bufferedEnd = b.end(i);
            // Guarded because a width write is a LAYOUT of the root layer, and the buffered
            // end only advances as the network fills -- a handful of times per track, not
            // once per animation frame.
            const w = Math.min(100, bufferedEnd / audio.duration * 100).toFixed(2) + '%';
            if (w === this._lastBufW) return;
            this._lastBufW = w;
            halo.style.width = w;
        },

        attachSlider() {
            const slider = document.getElementById('pb-slider');
            if (!slider || slider._nuagesAttached) return;
            slider._nuagesAttached = true;

            slider.addEventListener('mousedown',  () => { this._userSeeking = true;  });
            slider.addEventListener('mouseup',    () => { this._userSeeking = false; });
            slider.addEventListener('touchstart', () => { this._userSeeking = true;  }, { passive: true });
            slider.addEventListener('touchend',   () => { this._userSeeking = false; });

            slider.addEventListener('input', () => {
                const d = this._audio ? (isNaN(this._audio.duration) ? 0 : this._audio.duration) : 0;
                this._updateVisuals(parseFloat(slider.value) || 0, d);
            });
        },

        // ── Track loading ─────────────────────────────────────────────────────

        _clearState() {
            if (this._canPlayHandler) {
                this._audio?.removeEventListener('canplay', this._canPlayHandler);
                this._canPlayHandler = null;
            }
            if (this._hls) { this._hls.destroy(); this._hls = null; }
            if (this._currentBlobUrl) {
                try { URL.revokeObjectURL(this._currentBlobUrl); } catch {}
                this._currentBlobUrl = null;
            }
            this._stopRaf();
            this._stallStart = null;
            this._lastSeekTime = -Infinity;
            this._updateVisuals(0, 0);
            const halo = document.getElementById('pb-buffer-halo');
            if (halo) halo.style.width = '0%';
        },

        /**
         * Ends any crossfade in flight immediately, silencing and tearing down whichever
         * deck is not active. Transport that stops or replaces playback must call this:
         * pause(), stop() and the rest act on `this._audio`, which during a fade is only
         * the INCOMING deck -- the outgoing one would otherwise keep playing to its timer,
         * audible over a pause, or over a native DRM track that stop() was clearing the
         * way for.
         */
        _collapseCrossfade() {
            if (this._fadeTimer) { clearTimeout(this._fadeTimer); this._fadeTimer = null; }
            this._pendingFade = null;   // a fade still waiting to commit is simply dropped
            if (!this._decks) return;
            for (const d of this._decks) {
                if (d.audio === this._audio) continue;
                try { d.audio.pause(); d.audio.removeAttribute('src'); d.audio.load(); } catch {}
                if (d.hls) { try { d.hls.destroy(); } catch {} d.hls = null; }
                if (d.blobUrl) { try { URL.revokeObjectURL(d.blobUrl); } catch {} d.blobUrl = null; }
                if (d.gain) {
                    try { d.gain.gain.cancelScheduledValues(this._audioCtx?.currentTime ?? 0); } catch {}
                    d.gain.gain.value = 1;   // ready for its next turn as the incoming deck
                }
            }
        },

        /** 0 disables crossfading entirely, which is the default. */
        setCrossfade(seconds) {
            this._crossfadeSeconds = Math.max(0, Math.min(12, Number(seconds) || 0));
        },

        /**
         * Moves playback to the idle deck and fades between the two. The outgoing deck
         * keeps playing throughout -- that overlap is the whole point -- and is torn down
         * once the fade has finished.
         *
         * Its listeners are gated on being the active deck, so while it fades out it
         * cannot report time, report ending, or advance the queue.
         */
        _beginCrossfade(seconds) {
            const outgoing = this._decks[this._deckIndex];
            this._stashDeck(outgoing);

            if (this._decks.length < 2) this._decks.push(this._makeDeck());
            this._deckIndex = (this._deckIndex + 1) % 2;

            const incoming = this._decks[this._deckIndex];
            this._adoptDeck(incoming);

            // Come up silent. Its gain may not exist yet on this deck's first turn, in
            // which case _connectSource applies it once created.
            incoming.startSilent = true;
            if (incoming.gain) {
                try { incoming.gain.gain.cancelScheduledValues(this._audioCtx?.currentTime ?? 0); } catch {}
                incoming.gain.gain.value = 0;
            }

            // Nothing is faded or torn down yet. The outgoing track keeps playing at full
            // level until the incoming one is genuinely producing audio -- so a track that
            // fails to load can no longer cut the current one to silence.
            this._pendingFade = { outgoing, incoming, seconds };

            const cleanup = () => {
                incoming.audio.removeEventListener('playing', onPlaying);
                incoming.audio.removeEventListener('error', onError);
                if (guard) clearTimeout(guard);
            };
            const onPlaying = () => { cleanup(); this._commitCrossfade(); };
            const onError   = () => { cleanup(); this._abortCrossfade(); };

            // A load that neither plays nor errors (stalled network) would otherwise leave
            // the pair running at full level indefinitely.
            const guard = setTimeout(() => { cleanup(); this._abortCrossfade(); }, 15000);

            incoming.audio.addEventListener('playing', onPlaying);
            incoming.audio.addEventListener('error', onError);
        },

        /** The incoming deck is audible: cross the two gains and schedule the teardown. */
        _commitCrossfade() {
            const fade = this._pendingFade;
            if (!fade) return;
            this._pendingFade = null;

            const { outgoing, incoming, seconds } = fade;
            incoming.startSilent = false;

            if (incoming.gain) this._ramp(incoming.gain.gain, 1, seconds / 3);
            if (outgoing.gain) this._ramp(outgoing.gain.gain, 0, seconds / 3);

            // setTargetAtTime approaches zero without reaching it, so the outgoing deck is
            // stopped explicitly rather than left running quietly forever. The grace period
            // covers the tail of the ramp. Held so transport can collapse the fade early.
            this._fadeTimer = setTimeout(() => {
                this._fadeTimer = null;
                try {
                    outgoing.audio.pause();
                    outgoing.audio.removeAttribute('src');
                    outgoing.audio.load();
                } catch {}
                if (outgoing.hls) { try { outgoing.hls.destroy(); } catch {} outgoing.hls = null; }
                if (outgoing.blobUrl) {
                    try { URL.revokeObjectURL(outgoing.blobUrl); } catch {}
                    outgoing.blobUrl = null;
                }
                // Reset for its next turn as the incoming deck.
                if (outgoing.gain) {
                    try { outgoing.gain.gain.cancelScheduledValues(this._audioCtx?.currentTime ?? 0); } catch {}
                    outgoing.gain.gain.value = 1;
                }
            }, seconds * 1000 + 400);
        },

        /**
         * The incoming deck never played. Hand control back to the track still audible
         * rather than tearing it down: without this the outgoing deck was cut on schedule
         * and the result was silence. Making it active again also restores its transport --
         * its `ended` is gated on being the active deck, so a deck left inactive would
         * play out and then stop the queue dead.
         */
        _abortCrossfade() {
            const fade = this._pendingFade;
            if (!fade) return;
            this._pendingFade = null;

            const { outgoing, incoming } = fade;
            console.warn('[nuages] crossfade aborted \u2014 incoming deck never played');

            try {
                incoming.audio.pause();
                incoming.audio.removeAttribute('src');
                incoming.audio.load();
            } catch {}
            if (incoming.hls) { try { incoming.hls.destroy(); } catch {} incoming.hls = null; }
            if (incoming.blobUrl) {
                try { URL.revokeObjectURL(incoming.blobUrl); } catch {}
                incoming.blobUrl = null;
            }
            incoming.startSilent = false;
            if (incoming.gain) {
                try { incoming.gain.gain.cancelScheduledValues(this._audioCtx?.currentTime ?? 0); } catch {}
                incoming.gain.gain.value = 1;
            }

            this._stashDeck(incoming);
            this._deckIndex = this._decks.indexOf(outgoing);
            this._adoptDeck(outgoing);
            if (outgoing.gain) {
                try { outgoing.gain.gain.cancelScheduledValues(this._audioCtx?.currentTime ?? 0); } catch {}
                outgoing.gain.gain.value = 1;
            }
        },

        loadAndPlay(url) {
            if (!this._audio) return;

            // Crossfade only when it is enabled, something is actually playing to fade
            // FROM, and the native DRM relay is not driving transport (it owns its own
            // element and would be cut off mid-track). Otherwise this is the original
            // single-deck path, unchanged.
            const outgoing = this._decks?.[this._deckIndex];
            if (this._crossfadeSeconds > 0
                && outgoing
                && !outgoing.audio.paused
                && !outgoing.audio.ended
                && !this._nativeTransportActive) {
                this._beginCrossfade(this._crossfadeSeconds);
            }
            const isBlob = url.startsWith('blob:');
            // HLS detection via .m3u8 only — the AudioServer proxy now tags HLS
            // URLs with a `.m3u8` suffix, so progressive MP3 proxied through
            // `/proxy/{id}` no longer gets misrouted through the native-HLS
            // fallback (which bypasses Web Audio on WebKit, killing EQ/viz).
            const isHls  = url.includes('.m3u8');
            console.log(`[nuages] loadAndPlay type=${isBlob ? 'blob' : isHls ? 'hls/proxy' : 'progressive'} url=${url.substring(0, 80)}`);

            // Clear old HLS instance and state synchronously first.
            this._clearState();
            this._lastTimeSent = -Infinity;
            this._loadStartTime = performance.now();
            this._isFirstPlay = true;
            this._vizDataLogged = false;
            if (isBlob) this._currentBlobUrl = url;

            // Set crossOrigin BEFORE initAudioGraph so createMediaElementSource sees
            // the right CORS policy:
            //   blob: → null  (same-origin, CORS breaks blob loading)
            //   http: → "anonymous"  (AudioServer sends Access-Control-Allow-Origin: *)
            this._audio.crossOrigin = isBlob ? null : 'anonymous';
            console.log(`[nuages] loadAndPlay: crossOrigin=${this._audio.crossOrigin ?? 'null'}`);

            // Build the audio graph synchronously while still inside the user gesture
            // context — required for AudioContext.resume() to succeed on WebKit/Safari.
            this.initAudioGraph();

            if (isHls && !isBlob) { this._loadHls(url); return; }

            this._audio.src = url;
            this._audio.load();

            this._canPlayHandler = () => {
                this._audio.removeEventListener('canplay', this._canPlayHandler);
                this._canPlayHandler = null;
                console.log(`[nuages] canplay fired — ctx=${this._audioCtx?.state ?? 'none'}`);
                this._connectSource();
                this._audio.play().catch(e => console.warn('[nuages] play error:', e));
            };
            this._audio.addEventListener('canplay', this._canPlayHandler);
        },

        loadAndPlayDrm(url, licenseToken) {
            if (!this._audio) return;
            console.log(`[nuages] loadAndPlayDrm url=${url.substring(0, 80)}`);
            this._clearState();
            this._lastTimeSent = -Infinity;
            this._loadStartTime = performance.now();
            this._isFirstPlay = true;
            this._vizDataLogged = false;
            this._audio.crossOrigin = 'anonymous';
            this.initAudioGraph();
            this._loadHlsWithDrm(url, licenseToken);
        },

        _loadHlsWithDrm(url, licenseToken) {
            if (typeof Hls === 'undefined' || !Hls.isSupported()) {
                const msg = 'DRM: Hls.js not supported — encrypted track cannot play';
                console.warn('[nuages]', msg);
                try { DotNet.invokeMethodAsync('Nuages', 'ReceiveWebLog', 'error', msg); } catch {}
                return;
            }
            const licenseUrl = `https://license.media-streaming.soundcloud.cloud/playback/widevine?license_token=${encodeURIComponent(licenseToken)}`;
            console.log(`[nuages] _loadHlsWithDrm: licenseUrl len=${licenseUrl.length}`);
            const hls = new Hls({
                enableWorker: false,
                emeEnabled: true,
                widevineLicenseUrl: licenseUrl,
                // Specify an explicit EME robustness level. Without it hls.js warns and
                // browsers may pick inconsistent defaults. Audio-only Widevine in a desktop/
                // mobile WebView is software-protected, so SW_SECURE_CRYPTO is the right floor.
                drmSystemOptions: {
                    audioRobustness: 'SW_SECURE_CRYPTO',
                    videoRobustness: 'SW_SECURE_CRYPTO',
                },
                licenseXhrSetup(xhr) {
                    xhr.setRequestHeader('Content-Type', 'application/octet-stream');
                },
            });
            this._hls = hls;
            hls.loadSource(url);
            hls.attachMedia(this._audio);
            hls.on(Hls.Events.MANIFEST_PARSED, () => {
                console.log(`[nuages] hls DRM MANIFEST_PARSED — ctx=${this._audioCtx?.state ?? 'none'}`);
                this._connectSource();
                this._audio.play().catch(e => console.warn('[nuages] hls DRM play error:', e));
            });
            hls.on(Hls.Events.ERROR, (_, data) => {
                if (data.fatal) {
                    const msg = `HLS DRM fatal type=${data.type} details=${data.details}`;
                    console.error('[nuages]', msg);
                    try { DotNet.invokeMethodAsync('Nuages', 'ReceiveWebLog', 'error', msg); } catch {}
                }
            });
        },

        // ── FairPlay (Apple DRM) ────────────────────────────────────────────────
        // Plays SoundCloud's cbc-encrypted-hls (/cbcs/) variant, the only DRM Apple's
        // WebKit can decode. Cert + license both live at /playback/fairplay (cert = GET,
        // license = POST with the license_token). No Web Audio graph: FairPlay is a
        // protected pipeline, so EQ / visualizer are unavailable for these tracks.
        loadAndPlayFairPlay(url, licenseToken) {
            if (!this._audio) return;
            console.log(`[nuages] loadAndPlayFairPlay url=${url.substring(0, 80)}`);
            this._clearState();
            this._lastTimeSent = -Infinity;
            this._loadStartTime = performance.now();
            this._isFirstPlay = true;
            this._audio.crossOrigin = 'anonymous';
            this._loadHlsFairPlay(url, licenseToken);
        },

        _loadHlsFairPlay(url, licenseToken) {
            // Mac Catalyst's WKWebView has no MSE/ManagedMediaSource, so hls.js can't run —
            // non-DRM HLS already plays here via NATIVE HLS (audio.src=m3u8). For FairPlay we
            // do the same and drive the key exchange ourselves through the legacy WebKit EME
            // API (com.apple.fps.1_0), exactly as SoundCloud's own web player does.
            const audio = this._audio;
            const CERT_URL    = 'https://license.media-streaming.soundcloud.cloud/playback/fairplay';
            const LICENSE_URL = `${CERT_URL}?license_token=${encodeURIComponent(licenseToken)}`;
            const fail = (m) => {
                console.error('[nuages] FairPlay:', m);
                try { DotNet.invokeMethodAsync('Nuages', 'ReceiveWebLog', 'error', 'FairPlay: ' + m); } catch {}
            };

            // One-line capability probe so we know which DRM path (if any) the WebView allows.
            console.log(`[nuages] FairPlay caps: secureCtx=${window.isSecureContext} origin=${location.origin}`
                + ` EME=${typeof navigator.requestMediaKeySystemAccess} WKMediaKeys=${typeof WebKitMediaKeys}`
                + ` MSE=${typeof MediaSource} MMS=${typeof ManagedMediaSource}`
                + ` audio.webkitSetMediaKeys=${typeof audio.webkitSetMediaKeys}`
                + ` audio.setMediaKeys=${typeof audio.setMediaKeys}`);

            if (typeof WebKitMediaKeys === 'undefined') { fail('WebKitMediaKeys unavailable — no native FairPlay'); return; }
            if (!WebKitMediaKeys.isTypeSupported('com.apple.fps.1_0', 'video/mp4')) { fail('com.apple.fps.1_0 not supported'); return; }

            // Base64 helpers that tolerate large buffers (the SPC is ~11 KB).
            const b64enc = (bytes) => {
                let bin = ''; const CH = 0x8000;
                for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
                return btoa(bin);
            };
            const b64dec = (str) => { const r = atob(str); const a = new Uint8Array(r.length); for (let i = 0; i < r.length; i++) a[i] = r.charCodeAt(i); return a; };

            // initData = skd:// URI as UTF-16; content id is the full URI (SoundCloud's assetId).
            const contentIdOf = (initData) => String.fromCharCode.apply(null, new Uint16Array(initData.buffer));
            // Apple's concat layout: [len|initData][len|contentId utf16][len|cert], all u32 LE.
            const buildSessionInit = (initData, contentId, cert) => {
                const id = new Uint8Array(contentId.length * 2);
                for (let i = 0; i < contentId.length; i++) { const c = contentId.charCodeAt(i); id[i*2] = c & 0xff; id[i*2+1] = c >> 8; }
                const out = new Uint8Array(12 + initData.byteLength + id.byteLength + cert.byteLength);
                const dv = new DataView(out.buffer); let o = 0;
                dv.setUint32(o, initData.byteLength, true); o += 4; out.set(initData, o); o += initData.byteLength;
                dv.setUint32(o, id.byteLength, true);       o += 4; out.set(id, o);       o += id.byteLength;
                dv.setUint32(o, cert.byteLength, true);     o += 4; out.set(cert, o);
                return out;
            };

            let cert = null;
            const onNeedKey = (event) => {
                console.log('[nuages] FairPlay: webkitneedkey');
                try { if (!audio.webkitKeys) audio.webkitSetMediaKeys(new WebKitMediaKeys('com.apple.fps.1_0')); }
                catch (e) { fail('webkitSetMediaKeys: ' + e); return; }

                const initData  = new Uint8Array(event.initData);
                const contentId = contentIdOf(initData);
                console.log('[nuages] FairPlay: contentId=' + contentId.substring(0, 64));

                let session;
                try { session = audio.webkitKeys.createSession('video/mp4', buildSessionInit(initData, contentId, cert)); }
                catch (e) { fail('createSession: ' + e); return; }
                this._fpSession = session;

                session.addEventListener('webkitkeymessage', (e) => {
                    const spc = new Uint8Array(e.message);
                    console.log('[nuages] FairPlay: SPC len=' + spc.length + ' → POST license');
                    const body = 'spc=' + encodeURIComponent(b64enc(spc)) + '&assetId=' + encodeURIComponent(contentId);
                    fetch(LICENSE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
                        .then(r => { if (!r.ok) throw new Error('license HTTP ' + r.status); return r.text(); })
                        .then(txt => {
                            console.log('[nuages] FairPlay: CKC received len=' + txt.length);
                            try { session.update(b64dec(txt.trim())); }
                            catch (e2) { fail('session.update: ' + e2); }
                        })
                        .catch(err => fail('license request: ' + err));
                });
                session.addEventListener('webkitkeyerror', () => fail('webkitkeyerror code=' + (session.error && session.error.code)));
            };

            console.log('[nuages] FairPlay: fetching application certificate…');
            fetch(CERT_URL).then(r => { if (!r.ok) throw new Error('cert HTTP ' + r.status); return r.arrayBuffer(); })
                .then(buf => {
                    cert = new Uint8Array(buf);
                    console.log('[nuages] FairPlay: cert len=' + cert.length + ' — starting native HLS');
                    audio.addEventListener('webkitneedkey', onNeedKey);
                    audio.src = url;
                    audio.load();
                    audio.play().catch(e => console.warn('[nuages] FairPlay play error:', e));
                })
                .catch(err => fail('cert fetch: ' + err));
        },

        _loadHls(url) {
            // hls.js 1.5+ supports ManagedMediaSource (WKWebView on macOS 14+ / iOS 17+).
            // Hls.isSupported() checks MediaSource.isTypeSupported('video/mp4; codecs="avc1…"')
            // which returns false on Mac Catalyst even though audio-only HLS via
            // ManagedMediaSource works fine. Try hls.js whenever ManagedMediaSource exists.
            const canUseHlsJs = typeof Hls !== 'undefined' &&
                (Hls.isSupported() || typeof ManagedMediaSource !== 'undefined');

            if (!canUseHlsJs) {
                // True native HLS: WebKit plays m3u8 natively, but Web Audio cannot tap it.
                console.log(`[nuages] _loadHls: native HLS (no MSE/ManagedMediaSource)`);
                this._audio.src = url;
                this._audio.load();
                this._canPlayHandler = () => {
                    this._audio.removeEventListener('canplay', this._canPlayHandler);
                    this._canPlayHandler = null;
                    console.log(`[nuages] canplay (native HLS) — ctx=${this._audioCtx?.state ?? 'none'}`);
                    this._connectSource();
                    this._audio.play().catch(e => console.warn('[nuages] play error:', e));
                };
                this._audio.addEventListener('canplay', this._canPlayHandler);
                return;
            }

            const usingMms = !Hls.isSupported() && typeof ManagedMediaSource !== 'undefined';
            console.log(`[nuages] _loadHls: using hls.js (ManagedMediaSource=${usingMms})`);
            const hls = new Hls({ enableWorker: false });
            this._hls = hls;
            hls.loadSource(url);
            hls.attachMedia(this._audio);
            hls.on(Hls.Events.MANIFEST_PARSED, () => {
                console.log(`[nuages] hls MANIFEST_PARSED — ctx=${this._audioCtx?.state ?? 'none'}`);
                this._connectSource();
                this._audio.play().catch(e => console.warn('[nuages] hls play error:', e));
            });
            hls.on(Hls.Events.ERROR, (_, data) => {
                if (data.fatal) {
                    const msg = `HLS fatal error type=${data.type} details=${data.details}`;
                    console.error('[nuages]', msg);
                    try { DotNet.invokeMethodAsync('Nuages', 'ReceiveWebLog', 'error', msg); } catch {}
                    // Fatal error: fall back to native HLS playback.
                    console.log('[nuages] hls.js fatal — falling back to native HLS');
                    hls.destroy();
                    this._hls = null;
                    this._audio.src = url;
                    this._audio.load();
                    this._canPlayHandler = () => {
                        this._audio.removeEventListener('canplay', this._canPlayHandler);
                        this._canPlayHandler = null;
                        this._connectSource();
                        this._audio.play().catch(e => console.warn('[nuages] native fallback play error:', e));
                    };
                    this._audio.addEventListener('canplay', this._canPlayHandler);
                }
            });
        },

        // ── Playback controls ─────────────────────────────────────────────────

        async play() {
            if (!this._audio?.src || this._audio.error) return;
            if (this._audioCtx && this._audioCtx.state !== 'running')
                await this._audioCtx.resume().catch(() => {});
            try { 
                await this._audio.play();
                if (this._audioCtx && this._audioCtx.state !== 'running') {
                    await this._audioCtx.resume().catch(() => {});
                } 
            }
            catch (e) { console.warn('[nuages] play error:', e); }
        },

        pause() { this._collapseCrossfade(); this._audio?.pause(); },

        // Drive the progress bar + time labels from the native FairPlay player, which
        // doesn't go through the JS <audio> element (so its ticks otherwise never paint).
        updateProgress(position, duration) { this._updateVisuals(position, duration); },

        // Fully silence the WebView audio — used when a DRM track switches playback to the
        // native FairPlay AVPlayer so the two backends never play over each other.
        stop() {
            try {
                this._collapseCrossfade();
                if (this._hls) { this._hls.destroy(); this._hls = null; }
                if (this._audio) { this._audio.pause(); this._audio.removeAttribute('src'); this._audio.load(); }
                this._clearState();
            } catch (e) { console.warn('[nuages] stop error:', e); }
        },

        // When background playback is disabled, pause audio as soon as the app/page
        // is hidden (screen lock, app backgrounded). Default is to keep playing.
        _backgroundPlayback: true,
        _visibilityHooked: false,
        setBackgroundPlayback(enabled) {
            this._backgroundPlayback = !!enabled;
            if (!this._visibilityHooked) {
                this._visibilityHooked = true;
                document.addEventListener('visibilitychange', () => {
                    if (document.hidden && !this._backgroundPlayback)
                        this._audio?.pause();
                });
            }
        },

        seek(seconds) {
            if (!this._audio) return;
            this._lastSeekTime = performance.now();
            this._stallStart   = null;
            this._audio.currentTime = seconds;
            const d = isNaN(this._audio.duration) ? 0 : this._audio.duration;
            this._updateVisuals(seconds, d);
        },

        setVolume(vol) {
            const v = Math.max(0, Math.min(1, vol));
            if (this._volumeNode)
                this._ramp(this._volumeNode.gain, v);
            else if (this._audio)
                this._audio.volume = v;   // HTMLMediaElement.volume is not an AudioParam
        },

        dispose() {
            this._clearState();
            this._stopViz();
            if (this._audioCtx) { this._audioCtx.close().catch(() => {}); this._audioCtx = null; }
            if (this._audio) { this._audio.pause(); this._audio.src = ''; }
            this._volumeNode = null;
            this._ref = null;
        }
    }
};
