/**
 * Live 2-Channel Video Mixer Engine — v4.1
 * DJ-style mixing with crossfade, transitions, folder browsing + deep search,
 * online stock-video browser, tabbed deck panels (Browse/Playlist/Stock),
 * resizable layout, image slideshow, BroadcastChannel output sync,
 * drag & drop, VU meters, session save/load, BPM sync, per-deck FX,
 * playlist management, and dark/light/auto theme.
 */

const VIDEO_MIXER_VERSION = '4.1';

// ============================================================
// Deck Class
// ============================================================
class Deck {
    constructor(id) {
        this.id = id;
        this.video = document.getElementById(`deck-${id}-video`);
        this.previewCanvas = document.getElementById(`deck-${id}-preview`);
        this.previewCtx = this.previewCanvas.getContext('2d');
        this.nowPlaying = document.getElementById(`deck-${id}-now-playing`);
        this.errorEl = document.getElementById(`deck-${id}-error`);

        this.playlist = [];
        this.currentIndex = -1;
        this.playing = false;
        this.loop = false;
        this.shuffle = false;
        this.volume = 1.0;
        this.speed = 1.0;
        // 0 = OFF (image/visual stays on screen, no auto-advance). Slideshow mode = >0
        this.slideDuration = 0;

        this.currentImage = null;
        this.currentType = null;
        this.currentMediaUrl = '';
        this.imageTimer = null;
        this.imageLoadTime = 0;

        this.prevImage = null;
        this.imageFade = 1.0;
        this.imageFadeStart = 0;
        this.imageFading = false;

        // Legacy generative visual support for older saved sessions.
        this.visualInstance = null;

        this.browserPath = '';

        // Per-deck FX
        this.fx = { brightness: 100, contrast: 100, saturate: 100, hue: 0 };

        // Audio analysis for VU meter
        this._audioCtx = null;
        this._analyser = null;
        this._audioSource = null;
        this._audioData = null;

        // Track object URLs to revoke them and prevent memory leaks
        this._ownedObjectUrls = new Set();

        // Error retry counter to prevent infinite next() loops on broken playlists
        this._consecutiveErrors = 0;
        this._maxConsecutiveErrors = 5;
        this._errorTimer = null;

        // Guards async image callbacks when the operator cues sources rapidly.
        this._loadToken = 0;

        // Changes whenever a source is cued or cleared, even when its URL is unchanged.
        this.sourceRevision = 0;

        this._setupVideoEvents();
        this._setupAudio();
    }

    _setupVideoEvents() {
        this.video.addEventListener('ended', () => {
            this._consecutiveErrors = 0; // healthy playthrough resets counter
            if (this.loop) {
                this.video.currentTime = 0;
                this.video.play().catch(() => {});
            } else {
                this.next();
            }
        });

        this.video.addEventListener('playing', () => {
            this._consecutiveErrors = 0; // healthy play resets counter
        });

        this.video.addEventListener('error', () => {
            this._showError();
            this._handleMediaError();
        });
    }

    _handleMediaError() {
        this._consecutiveErrors++;
        if (this._consecutiveErrors >= this._maxConsecutiveErrors) {
            // Stop trying — entire playlist may be broken
            this.pause();
            this.nowPlaying.textContent = 'Stopped — too many errors';
            this._consecutiveErrors = 0;
            return;
        }
        clearTimeout(this._errorTimer);
        this._errorTimer = setTimeout(() => this.next(), 1000);
    }

    /** Revoke any object URLs we created for this deck */
    _revokeOwnedUrls() {
        for (const url of this._ownedObjectUrls) {
            try { URL.revokeObjectURL(url); } catch (e) {}
        }
        this._ownedObjectUrls.clear();
    }

    /** Track a URL so we can revoke it later */
    _trackUrl(url) {
        if (typeof url === 'string' && url.startsWith('blob:')) {
            this._ownedObjectUrls.add(url);
        }
    }

    _setupAudio() {
        try {
            this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            this._analyser = this._audioCtx.createAnalyser();
            this._analyser.fftSize = 256;
            this._audioSource = this._audioCtx.createMediaElementSource(this.video);
            this._audioSource.connect(this._analyser);
            this._analyser.connect(this._audioCtx.destination);
            this._audioData = new Uint8Array(this._analyser.frequencyBinCount);
        } catch (e) {
            // Audio analysis not available
        }
    }

    getAudioLevel() {
        if (!this._analyser || !this._audioData) return 0;
        this._analyser.getByteFrequencyData(this._audioData);
        let sum = 0;
        for (let i = 0; i < this._audioData.length; i++) {
            sum += this._audioData[i];
        }
        return sum / (this._audioData.length * 255);
    }

    _showError() {
        if (this.errorEl) {
            this.errorEl.classList.remove('hidden');
            setTimeout(() => this.errorEl.classList.add('hidden'), 4000);
        }
    }

    _hideError() {
        if (this.errorEl) this.errorEl.classList.add('hidden');
    }

    loadPlaylist(files) {
        // Revoke any previously-owned blob URLs to prevent memory leaks
        this._revokeOwnedUrls();
        this.playlist = files;
        this._consecutiveErrors = 0;
        // Track any new blob URLs in this playlist
        for (const f of files) {
            if (f && f._isObjectUrl) this._trackUrl(f.path);
        }
        if (files.length > 0) {
            this.loadItem(0);
        }
        renderPlaylist(this.id);
    }

    /** Append a single item to the playlist without interrupting playback. */
    addToPlaylist(item) {
        if (item && item._isObjectUrl) this._trackUrl(item.path);
        this.playlist.push(item);
        // First queued item: show it only when the deck is genuinely empty.
        // An ad-hoc visual may be live with currentIndex === -1.
        if (this.playlist.length === 1 && this.currentIndex === -1 && this.currentType === null) {
            this.loadItem(0);
        }
        renderPlaylist(this.id);
    }

    appendItems(items) {
        const clean = Array.isArray(items) ? items.filter(Boolean) : [];
        if (clean.length === 0) return;
        for (const item of clean) {
            if (item._isObjectUrl) this._trackUrl(item.path);
            this.playlist.push(item);
        }
        if (this.currentIndex === -1 && this.currentType === null) this.loadItem(0);
        renderPlaylist(this.id);
    }

    moveItem(fromIndex, toIndex) {
        if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) return false;
        if (fromIndex < 0 || fromIndex >= this.playlist.length) return false;
        const target = Math.max(0, Math.min(this.playlist.length - 1, toIndex));
        if (fromIndex === target) return false;
        const currentItem = this.playlist[this.currentIndex] || null;
        const [moved] = this.playlist.splice(fromIndex, 1);
        this.playlist.splice(target, 0, moved);
        this.currentIndex = currentItem ? this.playlist.indexOf(currentItem) : -1;
        renderPlaylist(this.id);
        return true;
    }

    duplicateAt(index) {
        if (index < 0 || index >= this.playlist.length) return false;
        const item = this.playlist[index];
        // Duplicating a blob URL makes safe lifecycle management ambiguous.
        if (!item || item._isObjectUrl) return false;
        this.playlist.splice(index + 1, 0, { ...item });
        if (index < this.currentIndex) this.currentIndex++;
        renderPlaylist(this.id);
        return true;
    }

    /** Remove the playlist item at index, adjusting playback if needed. */
    removeAt(index) {
        if (index < 0 || index >= this.playlist.length) return;
        const [removed] = this.playlist.splice(index, 1);
        if (removed && removed._isObjectUrl) {
            try { URL.revokeObjectURL(removed.path); } catch (e) {}
            this._ownedObjectUrls.delete(removed.path);
        }
        if (this.playlist.length === 0) {
            this._unloadCurrent();
        } else if (index === this.currentIndex) {
            this.loadItem(Math.min(index, this.playlist.length - 1));
        } else if (index < this.currentIndex) {
            this.currentIndex--;
        }
        renderPlaylist(this.id);
    }

    clearPlaylist() {
        this._revokeOwnedUrls();
        this.playlist = [];
        this._unloadCurrent();
        renderPlaylist(this.id);
    }

    _unloadCurrent() {
        this.sourceRevision++;
        this.pause();
        updatePlayButton(this.id, false);
        this.video.removeAttribute('src');
        try { this.video.load(); } catch (e) {}
        this.currentIndex = -1;
        this.currentType = null;
        this.currentImage = null;
        this.prevImage = null;
        this.visualInstance = null;
        this.currentMediaUrl = '';
        this.nowPlaying.textContent = 'No media loaded';
        this._highlightCurrent();
    }

    loadItem(index) {
        if (index < 0 || index >= this.playlist.length) return;
        this.sourceRevision++;
        const loadToken = ++this._loadToken;
        this.currentIndex = index;
        const item = this.playlist[index];
        this.nowPlaying.textContent = item.name;
        this._hideError();

        this.video.pause();
        this.video.removeAttribute('src');
        try { this.video.load(); } catch (e) {}
        clearTimeout(this.imageTimer);
        clearTimeout(this._errorTimer);
        this.imageFading = false;
        // Release stale image reference
        if (this.imageFade >= 1) this.prevImage = null;

        if (item.type === 'visual') {
            this._loadVisual(item.visualId, item.name);
        } else {
            this.visualInstance = null;
            // Drag-dropped files carry their own object URL; server files go via /api/media
            const mediaUrl = item._isObjectUrl
                ? item.path
                : `/api/media?path=${encodeURIComponent(item.path)}`;
            this.currentMediaUrl = mediaUrl;

            if (item.type === 'video') {
                this.currentType = 'video';
                this.currentImage = null;
                this.video.src = mediaUrl;
                this.video.volume = this.volume;
                this.video.playbackRate = this.speed;
                if (this.playing) {
                    this.video.play().catch(() => {});
                }
            } else {
                this.currentType = 'image';
                this.prevImage = this.currentImage;
                this.imageLoadTime = performance.now();
                const img = new Image();
                if (!item._isObjectUrl) img.crossOrigin = 'anonymous';
                img.onload = () => {
                    if (loadToken !== this._loadToken) return;
                    this._consecutiveErrors = 0;
                    this.currentImage = img;
                    if (this.prevImage) {
                        this.imageFading = true;
                        this.imageFadeStart = performance.now();
                        this.imageFade = 0;
                    } else {
                        this.imageFade = 1;
                    }
                };
                img.onerror = () => {
                    if (loadToken !== this._loadToken) return;
                    this._showError();
                    this._handleMediaError();
                };
                img.src = mediaUrl;

                if (this.playing) {
                    this._startImageTimer();
                }
            }
        }

        this._highlightCurrent();
    }

    /** Internal: set up a generative visual as the current source. */
    _loadVisual(visualId, name) {
        this.currentType = 'visual';
        this.currentImage = null;
        this.prevImage = null;
        const outW = (typeof mixer !== 'undefined' && mixer) ? mixer.outputW : 1280;
        const outH = (typeof mixer !== 'undefined' && mixer) ? mixer.outputH : 720;
        this.visualInstance = createVisualInstance(visualId, outW, outH);
        this.currentMediaUrl = `visual:${visualId}`;
        this.nowPlaying.textContent = name || (this.visualInstance ? this.visualInstance.name : visualId);
        this._consecutiveErrors = 0;
        this.imageLoadTime = performance.now();
        if (this.playing) {
            this._startImageTimer();
        }
    }

    /** Play a visual immediately without touching the playlist. */
    loadVisualNow(visualId, name) {
        this.sourceRevision++;
        this._hideError();
        this.video.pause();
        this.video.removeAttribute('src');
        try { this.video.load(); } catch (e) {}
        clearTimeout(this.imageTimer);
        clearTimeout(this._errorTimer);
        this.imageFading = false;
        this.currentIndex = -1;
        this._loadVisual(visualId, name);
        this._highlightCurrent();
    }

    /** Advance the generative visual — called once per frame by the mix engine. */
    updateVisual(now, bpm) {
        if (this.currentType !== 'visual' || !this.visualInstance) return;
        if (!this.playing) {
            // Freeze: keep the clock aligned so it doesn't jump on resume
            this.visualInstance._last = now;
            return;
        }
        this.visualInstance.tick(now, this.speed, bpm, this.getAudioLevel());
    }

    _startImageTimer() {
        clearTimeout(this.imageTimer);
        this.imageLoadTime = performance.now();
        // slideDuration of 0 = OFF — image/visual stays on screen, no auto-advance
        if (this.slideDuration <= 0) return;
        // Nothing to advance to when the source isn't part of the playlist
        if (this.currentIndex === -1) return;
        this.imageTimer = setTimeout(() => {
            if (this.playing && (this.currentType === 'image' || this.currentType === 'visual')) {
                this.next();
            }
        }, this.slideDuration * 1000);
    }

    _highlightCurrent() {
        // Browse tab file list
        const list = document.querySelector(`.file-list[data-deck="${this.id}"]`);
        if (list) {
            list.querySelectorAll('li').forEach(li => li.classList.remove('active'));
            const activeLi = list.querySelector(`li[data-index="${this.currentIndex}"]`);
            if (activeLi && this.currentIndex >= 0) {
                activeLi.classList.add('active');
                activeLi.scrollIntoView({ block: 'nearest' });
            }
        }
        // Playlist tab
        const plist = document.querySelector(`.playlist-list[data-deck="${this.id}"]`);
        if (plist) {
            plist.querySelectorAll('li').forEach(li => {
                li.classList.toggle('active', parseInt(li.dataset.index) === this.currentIndex);
            });
        }
        // Legacy procedural-bank marker (the v4 Stock UI has no such grid).
        const vscroll = document.querySelector(`.visuals-scroll[data-deck="${this.id}"]`);
        if (vscroll) {
            const playingId = this.currentType === 'visual' && this.visualInstance ? this.visualInstance.id : null;
            vscroll.querySelectorAll('.visual-tile').forEach(tile => {
                tile.classList.toggle('playing', tile.dataset.visualId === playingId);
            });
        }
    }

    play() {
        this.playing = true;
        if (this._audioCtx && this._audioCtx.state === 'suspended') {
            this._audioCtx.resume();
        }
        if (this.currentType === 'video') {
            this.video.play().catch(() => {});
        } else if (this.currentType === 'image' || this.currentType === 'visual') {
            this._startImageTimer();
        }
        if (this.currentIndex === -1 && this.currentType === null && this.playlist.length > 0) {
            this.loadItem(0);
        }
    }

    pause() {
        this.playing = false;
        if (this.currentType === 'video') {
            this.video.pause();
        }
        clearTimeout(this.imageTimer);
    }

    togglePlay() {
        if (this.playing) this.pause();
        else this.play();
        return this.playing;
    }

    next() {
        if (this.playlist.length === 0) return;
        let nextIdx = this.shuffle
            ? Math.floor(Math.random() * this.playlist.length)
            : (this.currentIndex + 1) % this.playlist.length;
        this.loadItem(nextIdx);
    }

    prev() {
        if (this.playlist.length === 0) return;
        let prevIdx = this.shuffle
            ? Math.floor(Math.random() * this.playlist.length)
            : (this.currentIndex - 1 + this.playlist.length) % this.playlist.length;
        this.loadItem(prevIdx);
    }

    setVolume(val) {
        this.volume = val;
        this.video.volume = val;
        // Update volume display
        const volVal = document.querySelector(`.vol-val[data-deck="${this.id}"]`);
        if (volVal) volVal.textContent = Math.round(val * 100);
        const volSlider = document.querySelector(`.vol-slider[data-deck="${this.id}"]`);
        if (volSlider) volSlider.value = Math.round(val * 100);
    }

    setSpeed(val) { this.speed = val; this.video.playbackRate = val; }
    setSlideDuration(val) { this.slideDuration = val; }

    setFx(key, val) { this.fx[key] = val; }
    resetFx() {
        this.fx = { brightness: 100, contrast: 100, saturate: 100, hue: 0 };
    }

    getFxFilter() {
        const f = this.fx;
        return `brightness(${f.brightness}%) contrast(${f.contrast}%) saturate(${f.saturate}%) hue-rotate(${f.hue}deg)`;
    }

    seek(fraction) {
        if (this.currentType === 'video' && this.video.duration) {
            this.video.currentTime = fraction * this.video.duration;
        }
    }

    getProgress() {
        if (this.currentType === 'video' && this.video.duration) {
            return this.video.currentTime / this.video.duration;
        }
        return 0;
    }

    getImageProgress() {
        if ((this.currentType === 'image' || this.currentType === 'visual')
            && this.playing && this.slideDuration > 0 && this.currentIndex >= 0) {
            const elapsed = (performance.now() - this.imageLoadTime) / 1000;
            return Math.min(elapsed / this.slideDuration, 1);
        }
        return 0;
    }

    getTimeDisplay() {
        if (this.currentType === 'video' && this.video.duration) {
            return `${fmtTime(this.video.currentTime)} / ${fmtTime(this.video.duration)}`;
        }
        if (this.currentType === 'image' || this.currentType === 'visual') {
            const label = this.currentType === 'visual' ? 'Vis' : 'Img';
            const counter = this.currentIndex >= 0
                ? `${label} ${this.currentIndex + 1}/${this.playlist.length}`
                : (this.currentType === 'visual' ? '&#x2728; Visual' : label);
            // Slideshow OFF — just show counter, no progress bar
            if (this.slideDuration <= 0 || this.currentIndex === -1) {
                return `<span>${counter}</span>`;
            }
            const pct = Math.round(this.getImageProgress() * 100);
            return `<span>${counter}</span><span class="slide-progress"><span class="slide-progress-fill" style="width:${pct}%"></span></span>`;
        }
        return '0:00 / 0:00';
    }

    getDrawable() {
        if (this.currentType === 'video') {
            if (this.video.readyState >= 2) return this.video;
            return null;
        }
        if (this.currentType === 'image' && this.currentImage) {
            return this.currentImage;
        }
        if (this.currentType === 'visual' && this.visualInstance) {
            return this.visualInstance.canvas;
        }
        return null;
    }

    getState() {
        return {
            mediaUrl: this.currentMediaUrl,
            mediaType: this.currentType,
            sourceRevision: this.sourceRevision,
            playing: this.playing,
            currentTime: this.currentType === 'video' ? this.video.currentTime : 0,
            speed: this.speed,
            volume: this.volume,
            fx: { ...this.fx },
        };
    }

    /** Serializable session data */
    getSessionData() {
        const currentItem = this.playlist[this.currentIndex] || null;
        // Browser-created blob URLs expire with the page and must never be restored.
        const portablePlaylist = this.playlist
            .filter(item => item && !item._isObjectUrl)
            .map(item => ({ ...item }));
        const portableCurrentIndex = currentItem && !currentItem._isObjectUrl
            ? this.playlist.slice(0, this.currentIndex + 1).filter(item => item && !item._isObjectUrl).length - 1
            : -1;
        return {
            playlist: portablePlaylist,
            currentIndex: portableCurrentIndex,
            playing: this.playing,
            loop: this.loop,
            shuffle: this.shuffle,
            volume: this.volume,
            speed: this.speed,
            slideDuration: this.slideDuration,
            fx: { ...this.fx },
            browserPath: this.browserPath,
        };
    }

    loadSessionData(data) {
        if (!data || typeof data !== 'object') return;

        this.loop = Boolean(data.loop);
        this.shuffle = Boolean(data.shuffle);
        this.volume = data.volume != null ? Math.max(0, Math.min(1, Number(data.volume))) : 1;
        this.speed = data.speed ? Math.max(0.25, Math.min(2, Number(data.speed))) : 1;
        this.slideDuration = data.slideDuration != null ? Math.max(0, Number(data.slideDuration)) : 0;
        this.fx = data.fx || { brightness: 100, contrast: 100, saturate: 100, hue: 0 };
        this.browserPath = data.browserPath || '';
        this.setVolume(this.volume);
        this.setSpeed(this.speed);

        if (Array.isArray(data.playlist)) {
            this._revokeOwnedUrls();
            this.playlist = data.playlist.filter(item => item
                && !item._isObjectUrl
                && ['video', 'image', 'visual'].includes(item.type)
                && (item.type === 'visual' ? item.visualId : item.path));
            if (this.playlist.length > 0) {
                const requested = Number.isInteger(data.currentIndex) ? data.currentIndex : 0;
                this.loadItem(Math.max(0, Math.min(this.playlist.length - 1, requested)));
                if (data.playing) this.play();
                else this.pause();
            } else {
                this._unloadCurrent();
            }
        }
        renderPlaylist(this.id);
    }

    drawPreview() {
        const c = this.previewCanvas;
        const ctx = this.previewCtx;
        const w = c.width = c.offsetWidth * window.devicePixelRatio;
        const h = c.height = c.offsetHeight * window.devicePixelRatio;

        ctx.clearRect(0, 0, w, h);
        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, w, h);

        // Apply per-deck FX
        const hasFx = this.fx.brightness !== 100 || this.fx.contrast !== 100 ||
                       this.fx.saturate !== 100 || this.fx.hue !== 0;
        if (hasFx) ctx.filter = this.getFxFilter();

        if (this.currentType === 'image' && this.imageFading) {
            const elapsed = (performance.now() - this.imageFadeStart) / 1000;
            this.imageFade = Math.min(elapsed / 0.5, 1.0);
            if (this.imageFade >= 1.0) {
                this.imageFading = false;
                this.prevImage = null;
            }

            if (this.prevImage) {
                ctx.globalAlpha = 1 - this.imageFade;
                drawFit(ctx, this.prevImage, w, h);
            }
            if (this.currentImage) {
                ctx.globalAlpha = this.imageFade;
                drawFit(ctx, this.currentImage, w, h);
            }
            ctx.globalAlpha = 1;
        } else {
            const src = this.getDrawable();
            if (src) drawFit(ctx, src, w, h);
        }

        if (hasFx) ctx.filter = 'none';
    }
}


// ============================================================
// Mix Engine
// ============================================================
class MixEngine {
    constructor(deckA, deckB) {
        this.deckA = deckA;
        this.deckB = deckB;
        this.canvas = document.getElementById('master-output');
        this.ctx = this.canvas.getContext('2d');
        this.miniCanvas = document.getElementById('mini-preview');
        this.miniCtx = this.miniCanvas ? this.miniCanvas.getContext('2d') : null;
        this.crossfade = 0.5;
        this.transition = 'crossfade';
        this.fx = { brightness: 100, contrast: 100, saturate: 100, hue: 0 };
        this.outputW = 1920;
        this.outputH = 1080;
        this._deckBuffers = {
            a: document.createElement('canvas'),
            b: document.createElement('canvas'),
        };

        this._autoMixing = false;
        this._autoMixStart = 0;
        this._autoMixFrom = 0;
        this._autoMixTo = 1;
        this._autoMixDuration = 2000;

        // Crossfader dead zones
        this._deadZone = 15; // out of 1000

        this._channel = new BroadcastChannel('mixer-sync');
        this._outputConnected = false;

        this._channel.onmessage = (e) => {
            if (e.data && e.data.type === 'output-heartbeat') {
                this._outputConnected = true;
                this._lastOutputHeartbeat = performance.now();
            }
        };
        this._lastOutputHeartbeat = 0;

        this._setResolution(1920, 1080);
        this._renderLoop();

        // Keep the output window fed even when this tab is backgrounded:
        // rAF pauses in hidden tabs, but timers keep firing (throttled).
        setInterval(() => this._broadcastState(), 250);
    }

    _setResolution(w, h) {
        this.outputW = w;
        this.outputH = h;
        this.canvas.width = w;
        this.canvas.height = h;
        for (const buffer of Object.values(this._deckBuffers)) {
            buffer.width = w;
            buffer.height = h;
        }
        // Legacy procedural sources are native-resolution; rebuild on resolution changes.
        for (const deck of [this.deckA, this.deckB]) {
            if (deck.currentType === 'visual' && deck.visualInstance
                && (deck.visualInstance.canvas.width !== w || deck.visualInstance.canvas.height !== h)) {
                deck._loadVisual(deck.visualInstance.id, deck.visualInstance.name);
            }
        }
    }

    setCrossfade(val) {
        // Apply dead zones at extremes
        if (val * 1000 <= this._deadZone) val = 0;
        else if (val * 1000 >= 1000 - this._deadZone) val = 1;
        this.crossfade = val;
    }

    setTransition(id) { this.transition = id; }
    setFx(key, val) { this.fx[key] = val; }
    resetFx() { this.fx = { brightness: 100, contrast: 100, saturate: 100, hue: 0 }; }

    startAutoMix(duration) {
        this._autoMixDuration = duration;
        this._autoMixFrom = this.crossfade;
        this._autoMixTo = this.crossfade < 0.5 ? 1.0 : 0.0;
        this._autoMixStart = performance.now();
        this._autoMixing = true;
    }

    _updateAutoMix() {
        if (!this._autoMixing) return;
        const elapsed = performance.now() - this._autoMixStart;
        const t = Math.min(elapsed / this._autoMixDuration, 1.0);
        const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
        this.crossfade = this._autoMixFrom + (this._autoMixTo - this._autoMixFrom) * eased;
        document.getElementById('crossfader').value = this.crossfade * 1000;
        if (t >= 1.0) {
            this._autoMixing = false;
            document.getElementById('auto-mix-btn').classList.remove('mixing');
        }
    }

    _broadcastState() {
        if (performance.now() - this._lastOutputHeartbeat > 3000) {
            this._outputConnected = false;
        }
        updateOutputStatus(this._outputConnected);

        this._channel.postMessage({
            crossfade: this.crossfade,
            transition: this.transition,
            fx: { ...this.fx },
            outputW: this.outputW,
            outputH: this.outputH,
            bpm: (typeof bpmSync !== 'undefined' && bpmSync) ? bpmSync.bpm : 0,
            deckA: this.deckA.getState(),
            deckB: this.deckB.getState(),
            sendAudio: true,
        });
    }

    _renderLoop() {
        const now = performance.now();
        const bpm = (typeof bpmSync !== 'undefined' && bpmSync) ? bpmSync.bpm : 0;
        this._updateAutoMix();
        this.deckA.updateVisual(now, bpm);
        this.deckB.updateVisual(now, bpm);
        this.deckA.drawPreview();
        this.deckB.drawPreview();
        this._renderMaster();
        this._renderMiniPreview();
        this._broadcastState();
        this._updateVU();
        requestAnimationFrame(() => this._renderLoop());
    }

    _updateVU() {
        const levelA = this.deckA.getAudioLevel();
        const levelB = this.deckB.getAudioLevel();
        const fillA = document.getElementById('vu-fill-a');
        const fillB = document.getElementById('vu-fill-b');
        if (fillA) {
            fillA.style.width = (levelA * 100) + '%';
            fillA.classList.toggle('hot', levelA > 0.8);
        }
        if (fillB) {
            fillB.style.width = (levelB * 100) + '%';
            fillB.classList.toggle('hot', levelB > 0.8);
        }
    }

    _renderMiniPreview() {
        if (!this.miniCanvas || !this.miniCtx) return;
        const mc = this.miniCanvas;
        const mctx = this.miniCtx;
        const dpr = window.devicePixelRatio || 1;
        mc.width = mc.offsetWidth * dpr;
        mc.height = mc.offsetHeight * dpr;
        if (this.canvas.width > 0 && this.canvas.height > 0) {
            mctx.drawImage(this.canvas, 0, 0, mc.width, mc.height);
        }
    }

    _renderMaster() {
        const ctx = this.ctx;
        const w = this.outputW;
        const h = this.outputH;
        const cf = this.crossfade;

        // Precompose sources when a deck filter or image fade is active. This makes
        // deck FX visible in the actual program feed, not only in deck previews.
        const srcA = this._getProgramSource(this.deckA, 'a');
        const srcB = this._getProgramSource(this.deckB, 'b');

        const filterStr = `brightness(${this.fx.brightness}%) contrast(${this.fx.contrast}%) saturate(${this.fx.saturate}%) hue-rotate(${this.fx.hue}deg)`;
        ctx.filter = filterStr;

        ctx.clearRect(0, 0, w, h);
        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, w, h);

        const transition = window.TransitionRegistry.get(this.transition);
        transition.render(ctx, srcA, srcB, cf, w, h, drawFit);

        ctx.filter = 'none';
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
    }

    _getProgramSource(deck, key) {
        const source = deck.getDrawable();
        if (!source) return null;
        const fx = deck.fx;
        const hasFx = fx.brightness !== 100 || fx.contrast !== 100
            || fx.saturate !== 100 || fx.hue !== 0;
        const needsImageBlend = deck.currentType === 'image' && deck.imageFading;
        if (!hasFx && !needsImageBlend) return source;

        const buffer = this._deckBuffers[key];
        const bctx = buffer.getContext('2d');
        const w = this.outputW;
        const h = this.outputH;
        bctx.clearRect(0, 0, w, h);
        bctx.save();
        if (hasFx) bctx.filter = deck.getFxFilter();
        if (needsImageBlend) {
            if (deck.prevImage) {
                bctx.globalAlpha = 1 - deck.imageFade;
                drawFit(bctx, deck.prevImage, w, h);
            }
            if (deck.currentImage) {
                bctx.globalAlpha = deck.imageFade;
                drawFit(bctx, deck.currentImage, w, h);
            }
        } else {
            drawFit(bctx, source, w, h);
        }
        bctx.restore();
        return buffer;
    }

    getSessionData() {
        return {
            crossfade: this.crossfade,
            transition: this.transition,
            fx: { ...this.fx },
            outputW: this.outputW,
            outputH: this.outputH,
        };
    }

    loadSessionData(data) {
        if (data.crossfade != null) {
            this.crossfade = data.crossfade;
            document.getElementById('crossfader').value = data.crossfade * 1000;
        }
        if (data.transition) this.transition = data.transition;
        if (data.fx) this.fx = data.fx;
        if (data.outputW && data.outputH) this._setResolution(data.outputW, data.outputH);
    }
}


// ============================================================
// Utility Functions
// ============================================================

function drawFit(ctx, src, canvasW, canvasH) {
    const srcW = src.videoWidth || src.naturalWidth || src.width;
    const srcH = src.videoHeight || src.naturalHeight || src.height;
    if (!srcW || !srcH) return;
    const scale = Math.max(canvasW / srcW, canvasH / srcH);
    const drawW = srcW * scale;
    const drawH = srcH * scale;
    const x = (canvasW - drawW) / 2;
    const y = (canvasH - drawH) / 2;
    ctx.drawImage(src, x, y, drawW, drawH);
}

function fmtTime(seconds) {
    const m = Math.floor(seconds / 60);
    const s = Math.floor(seconds % 60);
    return `${m}:${s.toString().padStart(2, '0')}`;
}

let _toastTimer = null;
function showToast(message) {
    const toast = document.getElementById('app-toast');
    if (!toast) return;
    toast.textContent = message;
    toast.classList.add('visible');
    clearTimeout(_toastTimer);
    _toastTimer = setTimeout(() => toast.classList.remove('visible'), 2400);
}

function escHtml(str) {
    const div = document.createElement('div');
    div.textContent = str;
    return div.innerHTML;
}

function itemIcon(type) {
    if (type === 'video') return '<span class="icon vid-icon">&#x1F3AC;</span>';
    if (type === 'visual') return '<span class="icon vis-icon">&#x2728;</span>';
    return '<span class="icon img-icon">&#x1F5BC;</span>';
}


// ============================================================
// Recent Folders (shared by both decks)
// ============================================================

const RECENT_DIRS_KEY = 'mixer-recent-dirs';

function getRecentDirs() {
    try {
        return JSON.parse(localStorage.getItem(RECENT_DIRS_KEY)) || [];
    } catch (e) { return []; }
}

function saveRecentDir(path) {
    if (!path) return;
    let recents = getRecentDirs().filter(p => p !== path);
    recents.unshift(path);
    recents = recents.slice(0, 10);
    try { localStorage.setItem(RECENT_DIRS_KEY, JSON.stringify(recents)); } catch (e) {}
}


// ============================================================
// File Browser — with live filter + deep search + breadcrumbs
// ============================================================
class FileBrowser {
    constructor(deckId, deck) {
        this.deckId = deckId;
        this.deck = deck;
        this.currentPath = '';
        this.currentDirs = [];
        this.currentFiles = [];
        this.parentPath = '';

        this.searchQuery = '';
        this.deepResults = null;   // array when a deep search is active
        this.deepTruncated = false;
        this.viewFiles = [];       // files currently visible (filtered/search)

        this.pathDisplay = document.querySelector(`.browser-path[data-deck="${deckId}"]`);
        this.fileList = document.querySelector(`.file-list[data-deck="${deckId}"]`);
        this.pathInput = document.querySelector(`.path-input[data-deck="${deckId}"]`);
        this.searchInput = document.querySelector(`.search-input[data-deck="${deckId}"]`);
        this.searchClear = document.querySelector(`.search-clear[data-deck="${deckId}"]`);
        this.searchStatus = document.querySelector(`.search-status[data-deck="${deckId}"]`);
        this.recentMenu = document.querySelector(`.recent-menu[data-deck="${deckId}"]`);
        this.loadBtn = document.querySelector(`.load-folder-btn[data-deck="${deckId}"]`);

        this._searchDebounce = null;

        this._setupEvents();
        this._loadHome();
    }

    _setupEvents() {
        document.querySelectorAll(`.nav-btn[data-deck="${this.deckId}"]`).forEach(btn => {
            btn.addEventListener('click', () => {
                const nav = btn.dataset.nav;
                if (nav === 'up' && this.currentPath) {
                    this.browse(this.parentPath || '/');
                } else if (nav === 'home') {
                    this._loadHome();
                } else if (nav === 'recent') {
                    this._toggleRecentMenu();
                }
            });
        });

        this.pathInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                const val = this.pathInput.value.trim();
                if (val) this.browse(val);
            }
        });

        // Search: live filter as you type, Enter = deep (recursive) search
        this.searchInput.addEventListener('input', () => {
            clearTimeout(this._searchDebounce);
            this._searchDebounce = setTimeout(() => {
                this.searchQuery = this.searchInput.value.trim();
                this.deepResults = null;
                this._render();
            }, 150);
        });

        this.searchInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                e.preventDefault();
                clearTimeout(this._searchDebounce);
                this.searchQuery = this.searchInput.value.trim();
                if (this.searchQuery) this.deepSearch();
            } else if (e.key === 'Escape') {
                this.clearSearch();
                this.searchInput.blur();
            }
        });

        this.searchClear.addEventListener('click', () => this.clearSearch());

        this.loadBtn.addEventListener('click', () => {
            if (this.viewFiles.length > 0) {
                this.deck.loadPlaylist([...this.viewFiles]);
                this._render();
            }
        });

        // Close recent menu on outside click
        document.addEventListener('click', (e) => {
            if (!this.recentMenu.classList.contains('hidden')
                && !e.target.closest(`.browser-nav`)) {
                this.recentMenu.classList.add('hidden');
            }
        });
    }

    _toggleRecentMenu() {
        // Close the other deck's menu first
        document.querySelectorAll('.recent-menu').forEach(m => {
            if (m !== this.recentMenu) m.classList.add('hidden');
        });
        if (!this.recentMenu.classList.contains('hidden')) {
            this.recentMenu.classList.add('hidden');
            return;
        }
        const recents = getRecentDirs();
        this.recentMenu.innerHTML = '';
        if (recents.length === 0) {
            this.recentMenu.innerHTML = '<div class="recent-empty">No recent folders yet</div>';
        } else {
            recents.forEach(p => {
                const item = document.createElement('div');
                item.className = 'recent-item';
                item.textContent = p;
                item.title = p;
                item.addEventListener('click', () => {
                    this.recentMenu.classList.add('hidden');
                    this.browse(p);
                });
                this.recentMenu.appendChild(item);
            });
        }
        this.recentMenu.classList.remove('hidden');
    }

    async _loadHome() {
        try {
            const resp = await fetch('/api/home');
            const data = await resp.json();
            this.browse(data.path);
        } catch (e) {
            this.browse('/');
        }
    }

    async browse(path) {
        try {
            const resp = await fetch(`/api/browse?path=${encodeURIComponent(path)}`);
            if (!resp.ok) return;
            const data = await resp.json();
            this.currentPath = data.current;
            this.parentPath = data.parent;
            this.deck.browserPath = data.current;
            this.pathInput.value = data.current;
            this.currentDirs = data.dirs;
            this.currentFiles = data.files;
            this.clearSearch(false);
            saveRecentDir(data.current);
            this._renderBreadcrumb();
            this._render();
        } catch (e) {
            console.error('Browse error:', e);
        }
    }

    async deepSearch() {
        const q = this.searchQuery;
        if (!q || !this.currentPath) return;
        this.searchStatus.textContent = 'Searching subfolders...';
        this.searchStatus.classList.remove('hidden');
        try {
            const resp = await fetch(`/api/search?path=${encodeURIComponent(this.currentPath)}&q=${encodeURIComponent(q)}`);
            if (!resp.ok) {
                this.searchStatus.textContent = 'Search failed';
                return;
            }
            const data = await resp.json();
            // Ignore stale responses if the user changed the query meanwhile
            if (data.query !== this.searchQuery.toLowerCase()) return;
            this.deepResults = data.results;
            this.deepTruncated = data.truncated;
            this.deepTimedOut = !!data.timedOut;
            this._render();
        } catch (e) {
            this.searchStatus.textContent = 'Search failed';
            console.error('Deep search error:', e);
        }
    }

    clearSearch(rerender = true) {
        this.searchQuery = '';
        this.deepResults = null;
        this.deepTruncated = false;
        this.deepTimedOut = false;
        if (this.searchInput) this.searchInput.value = '';
        if (rerender) this._render();
    }

    _renderBreadcrumb() {
        const path = this.currentPath;
        this.pathDisplay.innerHTML = '';
        if (!path) return;
        const sep = path.includes('\\') ? '\\' : '/';
        const parts = path.split(sep).filter(Boolean);
        const frag = document.createDocumentFragment();

        const rootCrumb = document.createElement('span');
        rootCrumb.className = 'crumb';
        rootCrumb.textContent = sep === '/' ? '/' : path.slice(0, path.indexOf(sep) + 1);
        rootCrumb.addEventListener('click', () => this.browse(sep === '/' ? '/' : rootCrumb.textContent));
        frag.appendChild(rootCrumb);

        let acc = sep === '/' ? '' : parts.shift();
        parts.forEach((part) => {
            acc += sep + part;
            const sepEl = document.createElement('span');
            sepEl.className = 'crumb-sep';
            sepEl.textContent = '›';
            frag.appendChild(sepEl);
            const crumb = document.createElement('span');
            crumb.className = 'crumb';
            crumb.textContent = part;
            const target = acc;
            crumb.addEventListener('click', () => this.browse(target));
            frag.appendChild(crumb);
        });
        this.pathDisplay.appendChild(frag);
        // Keep the tail of long paths visible
        this.pathDisplay.scrollLeft = this.pathDisplay.scrollWidth;
    }

    _updateStatus() {
        if (this.deepResults) {
            const n = this.deepResults.length;
            let note = '';
            if (this.deepTruncated) note = ' (showing first 400)';
            else if (this.deepTimedOut) note = ' (big folder — search stopped early)';
            this.searchStatus.textContent =
                `${n} result${n === 1 ? '' : 's'} incl. subfolders${note}`;
            this.searchStatus.classList.remove('hidden');
        } else if (this.searchQuery) {
            const n = this.viewFiles.length;
            this.searchStatus.textContent =
                `${n} match${n === 1 ? '' : 'es'} in this folder — Enter searches subfolders`;
            this.searchStatus.classList.remove('hidden');
        } else {
            this.searchStatus.classList.add('hidden');
        }
        this.searchClear.classList.toggle('hidden', !this.searchQuery && !this.deepResults);
    }

    _updateLoadBtn() {
        const n = this.viewFiles.length;
        const deckName = this.deckId.toUpperCase();
        if (this.searchQuery || this.deepResults) {
            this.loadBtn.textContent = `Load ${n} Result${n === 1 ? '' : 's'} to Deck ${deckName}`;
        } else {
            this.loadBtn.textContent = `Load All to Deck ${deckName}`;
        }
    }

    _render() {
        this.fileList.innerHTML = '';
        const q = this.searchQuery.toLowerCase();

        if (this.deepResults) {
            this.viewFiles = this.deepResults;
            this.deepResults.forEach((file, idx) => this._appendFileRow(file, idx, true));
        } else {
            // ".." row
            if (this.parentPath && this.parentPath !== this.currentPath && !q) {
                const li = document.createElement('li');
                li.innerHTML = '<span class="icon dir-icon">&#x1F4C1;</span><span class="file-name">..</span>';
                li.addEventListener('click', () => this.browse(this.parentPath));
                this.fileList.appendChild(li);
            }
            const dirs = q ? this.currentDirs.filter(d => d.name.toLowerCase().includes(q)) : this.currentDirs;
            dirs.forEach(dir => {
                const li = document.createElement('li');
                li.innerHTML = `<span class="icon dir-icon">&#x1F4C1;</span><span class="file-name">${escHtml(dir.name)}</span>`;
                li.addEventListener('click', () => this.browse(dir.path));
                this.fileList.appendChild(li);
            });
            this.viewFiles = q
                ? this.currentFiles.filter(f => f.name.toLowerCase().includes(q))
                : this.currentFiles;
            this.viewFiles.forEach((file, idx) => this._appendFileRow(file, idx, false));
        }

        this._updateStatus();
        this._updateLoadBtn();
        this.deck._highlightCurrent();
    }

    _appendFileRow(file, idx, showRel) {
        const li = document.createElement('li');
        const iconClass = file.type === 'video' ? 'vid-icon' : 'img-icon';
        const icon = file.type === 'video' ? '&#x1F3AC;' : '&#x1F5BC;';
        const relLine = showRel && file.rel && file.rel !== file.name
            ? `<span class="file-rel">${escHtml(file.rel)}</span>` : '';
        li.innerHTML = `<span class="icon ${iconClass}">${icon}</span>`
            + `<span class="file-name">${escHtml(file.name)}${relLine}</span>`
            + `<button class="file-add-btn" title="Add to playlist">+</button>`;
        li.dataset.index = idx;

        if (this.deck.playlist.length > 0 &&
            this.deck.currentIndex >= 0 &&
            this.deck.playlist[this.deck.currentIndex]?.path === file.path) {
            li.classList.add('active');
        }

        li.querySelector('.file-add-btn').addEventListener('click', (e) => {
            e.stopPropagation();
            this.deck.addToPlaylist({ name: file.name, path: file.path, type: file.type, ext: file.ext });
        });

        li.addEventListener('dblclick', () => {
            this.deck.loadPlaylist([...this.viewFiles]);
            this.deck.loadItem(idx);
            this.deck.play();
            updatePlayButton(this.deckId, true);
        });

        this.fileList.appendChild(li);
    }
}


// ============================================================
// Playlist Panel
// ============================================================

function renderPlaylist(deckId) {
    const deck = deckId === 'a' ? deckA : deckB;
    if (!deck) return;
    const list = document.querySelector(`.playlist-list[data-deck="${deckId}"]`);
    const empty = document.querySelector(`.playlist-empty[data-deck="${deckId}"]`);
    const badge = document.querySelector(`.tab-badge[data-deck="${deckId}"]`);
    const count = document.querySelector(`.playlist-count[data-deck="${deckId}"]`);
    const position = document.querySelector(`.playlist-position[data-deck="${deckId}"]`);
    const search = document.querySelector(`.playlist-search[data-deck="${deckId}"]`);
    if (!list) return;

    list.innerHTML = '';
    const n = deck.playlist.length;
    const filter = search ? search.value.trim().toLowerCase() : '';
    if (badge) badge.textContent = n;
    if (count) count.textContent = `${n} item${n === 1 ? '' : 's'}`;
    if (position) {
        position.textContent = deck.currentIndex >= 0
            ? `Now ${deck.currentIndex + 1} · Next ${n > 1 ? ((deck.currentIndex + 1) % n) + 1 : '—'}`
            : 'Ready';
    }
    if (empty) empty.classList.toggle('visible', n === 0);

    deck.playlist.forEach((item, idx) => {
        const li = document.createElement('li');
        li.dataset.index = idx;
        if (filter && !`${item.name || ''} ${item.type || ''}`.toLowerCase().includes(filter)) {
            li.classList.add('filtered-out');
        }

        const indexEl = document.createElement('span');
        indexEl.className = 'pl-index';
        indexEl.textContent = String(idx + 1).padStart(2, '0');
        li.appendChild(indexEl);

        const icon = document.createElement('span');
        icon.className = `icon ${item.type === 'video' ? 'vid-icon' : item.type === 'visual' ? 'vis-icon' : 'img-icon'}`;
        icon.textContent = item.type === 'video' ? '▸' : item.type === 'visual' ? '✦' : '▧';
        li.appendChild(icon);

        const info = document.createElement('span');
        info.className = 'pl-info';
        const name = document.createElement('span');
        name.className = 'pl-name';
        name.textContent = item.name || 'Untitled';
        name.title = item.name || 'Untitled';
        const meta = document.createElement('span');
        meta.className = 'pl-meta';
        const statusLabel = idx === deck.currentIndex ? 'Now playing' : idx === (deck.currentIndex + 1) % n ? 'Up next' : 'Queued';
        meta.textContent = `${item.type || 'media'} · ${statusLabel}`;
        info.append(name, meta);
        li.appendChild(info);

        const tools = document.createElement('span');
        tools.className = 'pl-tools';
        const addTool = (className, text, title, handler, disabled = false) => {
            const btn = document.createElement('button');
            btn.className = `pl-tool ${className}`;
            btn.type = 'button';
            btn.textContent = text;
            btn.title = title;
            btn.setAttribute('aria-label', title);
            btn.disabled = disabled;
            btn.addEventListener('click', (e) => { e.stopPropagation(); handler(); });
            tools.appendChild(btn);
        };
        addTool('play', '▶', 'Play now', () => {
            deck.loadItem(idx);
            deck.play();
            updatePlayButton(deckId, true);
        });
        addTool('up', '↑', 'Move up', () => deck.moveItem(idx, idx - 1), idx === 0);
        addTool('down', '↓', 'Move down', () => deck.moveItem(idx, idx + 1), idx === n - 1);
        addTool('duplicate', '⧉', 'Duplicate item', () => {
            if (!deck.duplicateAt(idx)) showToast('Dropped local files cannot be duplicated safely');
        }, Boolean(item._isObjectUrl));
        addTool('remove', '×', 'Remove from set', () => deck.removeAt(idx));
        li.appendChild(tools);

        if (idx === deck.currentIndex) li.classList.add('active');
        if (n > 1 && idx === (deck.currentIndex + 1) % n) li.classList.add('is-next');

        li.addEventListener('dblclick', () => {
            deck.loadItem(idx);
            deck.play();
            updatePlayButton(deckId, true);
        });

        // Drag reorder
        li.draggable = true;
        li.addEventListener('dragstart', (e) => {
            e.dataTransfer.setData('application/x-video-mixer-item', JSON.stringify({ deckId, index: idx }));
            e.dataTransfer.effectAllowed = 'move';
            li.classList.add('dragging');
        });
        li.addEventListener('dragend', () => li.classList.remove('dragging'));
        li.addEventListener('dragover', (e) => {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'move';
            const after = e.clientY > li.getBoundingClientRect().top + li.offsetHeight / 2;
            li.classList.toggle('drag-over', !after);
            li.classList.toggle('drag-after', after);
        });
        li.addEventListener('dragleave', () => li.classList.remove('drag-over', 'drag-after'));
        li.addEventListener('drop', (e) => {
            e.preventDefault();
            const after = e.clientY > li.getBoundingClientRect().top + li.offsetHeight / 2;
            li.classList.remove('drag-over', 'drag-after');
            try {
                const payload = JSON.parse(e.dataTransfer.getData('application/x-video-mixer-item'));
                if (!payload || payload.deckId !== deckId) return;
                let target = idx + (after ? 1 : 0);
                if (payload.index < target) target--;
                deck.moveItem(payload.index, target);
            } catch (err) {}
        });

        list.appendChild(li);
    });
}

function setupPlaylistPanels() {
    document.querySelectorAll('.playlist-clear').forEach(btn => {
        btn.addEventListener('click', () => {
            const deck = btn.dataset.deck === 'a' ? deckA : deckB;
            if (deck.playlist.length === 0) return;
            if (window.confirm(`Clear all ${deck.playlist.length} items from Deck ${btn.dataset.deck.toUpperCase()}?`)) {
                deck.clearPlaylist();
                showToast(`Deck ${btn.dataset.deck.toUpperCase()} set cleared`);
            }
        });
    });

    document.querySelectorAll('.playlist-copy').forEach(btn => {
        btn.addEventListener('click', () => {
            const sourceId = btn.dataset.deck;
            const source = sourceId === 'a' ? deckA : deckB;
            const target = sourceId === 'a' ? deckB : deckA;
            const portable = source.playlist.filter(item => !item._isObjectUrl).map(item => ({ ...item }));
            if (portable.length === 0) return showToast('No portable items to copy');
            target.appendItems(portable);
            showToast(`Added ${portable.length} item${portable.length === 1 ? '' : 's'} to Deck ${sourceId === 'a' ? 'B' : 'A'}`);
        });
    });

    document.querySelectorAll('.playlist-export').forEach(btn => {
        btn.addEventListener('click', () => exportPlaylist(btn.dataset.deck));
    });

    document.querySelectorAll('.playlist-import').forEach(btn => {
        btn.addEventListener('click', () => importPlaylist(btn.dataset.deck));
    });

    document.querySelectorAll('.playlist-search').forEach(input => {
        input.addEventListener('input', () => {
            const clear = document.querySelector(`.playlist-search-clear[data-deck="${input.dataset.deck}"]`);
            if (clear) clear.classList.toggle('hidden', input.value.length === 0);
            renderPlaylist(input.dataset.deck);
        });
    });

    document.querySelectorAll('.playlist-search-clear').forEach(btn => {
        btn.addEventListener('click', () => {
            const input = document.querySelector(`.playlist-search[data-deck="${btn.dataset.deck}"]`);
            if (input) input.value = '';
            btn.classList.add('hidden');
            renderPlaylist(btn.dataset.deck);
        });
    });
}

function exportPlaylist(deckId) {
    const deck = deckId === 'a' ? deckA : deckB;
    const items = deck.playlist.filter(item => !item._isObjectUrl).map(item => ({ ...item }));
    if (items.length === 0) return showToast('This set has no portable items to save');
    const blob = new Blob([JSON.stringify({ version: 1, type: 'video-mixer-playlist', items }, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = `deck-${deckId}-set-${new Date().toISOString().slice(0, 10)}.json`;
    link.click();
    URL.revokeObjectURL(url);
    showToast(`Saved ${items.length} playlist items`);
}

function importPlaylist(deckId) {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.json,application/json';
    input.addEventListener('change', () => {
        const file = input.files && input.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = () => {
            try {
                const data = JSON.parse(reader.result);
                const sourceItems = Array.isArray(data) ? data : data.items;
                const items = (Array.isArray(sourceItems) ? sourceItems : []).filter(item => item
                    && ['video', 'image', 'visual'].includes(item.type)
                    && (item.type === 'visual' ? item.visualId : item.path)
                    && !item._isObjectUrl);
                if (items.length === 0) throw new Error('No compatible playlist items found');
                const deck = deckId === 'a' ? deckA : deckB;
                deck.appendItems(items.map(item => ({ ...item })));
                showToast(`Loaded ${items.length} item${items.length === 1 ? '' : 's'} into Deck ${deckId.toUpperCase()}`);
            } catch (err) {
                showToast(`Could not load set: ${err.message}`);
            }
        };
        reader.readAsText(file);
    });
    input.click();
}


// ============================================================
// Deck Tabs (Browse | Playlist | Stock)
// ============================================================

function setupDeckTabs() {
    document.querySelectorAll('.deck-tab').forEach(tab => {
        tab.addEventListener('click', () => {
            const deckId = tab.dataset.deck;
            const target = tab.dataset.tab;
            document.querySelectorAll(`.deck-tab[data-deck="${deckId}"]`).forEach(t =>
                t.classList.toggle('active', t === tab));
            document.querySelectorAll(`.deck-panels[data-deck="${deckId}"] .deck-panel`).forEach(p =>
                p.classList.toggle('active', p.dataset.panel === target));
            if (target === 'visuals') {
                const input = document.querySelector(`.stock-search-input[data-deck="${deckId}"]`);
                if (input && !input.disabled) setTimeout(() => input.focus(), 0);
            }
        });
    });
}


// ============================================================
// Pixabay Stock Video Browser
// ============================================================

const STOCK_STARTERS = ['concert visual', 'stage lights', 'geometric', 'high contrast', 'abstract loop', 'motion background'];
const PIXABAY_LICENSE_URL = 'https://pixabay.com/service/license-summary/';
const PIXABAY_API_URL = 'https://pixabay.com/api/docs/';

const stockBrowser = {
    status: null,
    statusLoading: true,
    statusError: '',
    cachedItems: [],
    cacheLoaded: false,
    cacheLoading: false,
    cacheError: '',
    cacheByAssetId: new Map(),
    inflight: new Map(),
    busy: new Map(),
    panels: {
        a: { mode: 'discover', query: '', page: 1, pageSize: 24, total: 0, results: [], loading: false, error: '', errors: {}, searchToken: 0, selected: null, configSaving: false, configError: '', showConfig: false },
        b: { mode: 'discover', query: '', page: 1, pageSize: 24, total: 0, results: [], loading: false, error: '', errors: {}, searchToken: 0, selected: null, configSaving: false, configError: '', showConfig: false },
    },
};

async function stockJson(url, options = {}) {
    const response = await fetch(url, options);
    let data = {};
    try { data = await response.json(); } catch (e) {}
    const backendError = data.error && typeof data.error === 'object'
        ? data.error.message
        : data.error;
    if (!response.ok) throw new Error(backendError || data.message || `Request failed (${response.status})`);
    return data;
}

function stockAssetId(asset) {
    return String(asset && (asset.assetId != null ? asset.assetId : asset.id != null ? asset.id : asset.path || ''));
}

function stockDeck(deckId) {
    return deckId === 'a' ? deckA : deckB;
}

function stockCreator(asset) {
    if (!asset) return 'Pixabay contributor';
    return typeof asset.creator === 'string'
        ? asset.creator
        : (asset.creator && asset.creator.name) || 'Pixabay contributor';
}

function stockCreatorLink(asset, fallback) {
    const creatorUrl = asset && asset.creator && typeof asset.creator === 'object'
        ? asset.creator.url
        : '';
    return stockLink(creatorUrl || fallback);
}

function stockTitle(asset) {
    if (!asset) return 'Stock video';
    if (asset.name) return asset.name;
    if (Array.isArray(asset.tags) && asset.tags.length) return asset.tags.slice(0, 3).join(', ');
    return 'Pixabay stock video';
}

function stockLink(url) {
    try {
        const parsed = new URL(String(url || ''), window.location.href);
        return (parsed.protocol === 'http:' || parsed.protocol === 'https:') ? parsed.href : '#';
    } catch (e) { return '#'; }
}

function stockBytes(bytes) {
    const n = Number(bytes) || 0;
    if (!n) return '0 MB';
    if (n < 1024 * 1024) return `${Math.max(1, Math.round(n / 1024))} KB`;
    return `${(n / (1024 * 1024)).toFixed(n >= 100 * 1024 * 1024 ? 0 : 1)} MB`;
}

function stockDuration(seconds) {
    const n = Math.max(0, Math.round(Number(seconds) || 0));
    return n ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : '';
}

function stockCachedItem(asset) {
    if (asset && asset.type === 'video' && asset.path) return asset;
    return stockBrowser.cacheByAssetId.get(stockAssetId(asset)) || null;
}

function setStockMode(deckId, mode) {
    const state = stockBrowser.panels[deckId];
    state.mode = mode === 'cached' ? 'cached' : 'discover';
    document.querySelectorAll(`.stock-mode[data-deck="${deckId}"]`).forEach(button => {
        const active = button.dataset.mode === state.mode;
        button.classList.toggle('active', active);
        button.setAttribute('aria-selected', String(active));
    });
    if (state.mode === 'cached') loadStockCache();
    else renderStockPanel(deckId);
}

function updateStockChrome(deckId) {
    const status = stockBrowser.status;
    const input = document.querySelector(`.stock-search-input[data-deck="${deckId}"]`);
    const submit = document.querySelector(`.stock-search-form[data-deck="${deckId}"] .stock-search-btn`);
    const enabled = Boolean(status && status.configured && !stockBrowser.statusLoading);
    if (input) input.disabled = !enabled;
    if (submit) submit.disabled = !enabled;
    const summary = document.querySelector(`.stock-cache-summary[data-deck="${deckId}"]`);
    if (summary) {
        const count = status ? Number(status.cacheCount) || 0 : 0;
        summary.textContent = status
            ? `${count} cached · ${stockBytes(status.cacheBytes)}`
            : 'Checking cache...';
    }
    const configButton = document.querySelector(`.stock-config-btn[data-deck="${deckId}"]`);
    if (configButton) configButton.classList.toggle('hidden', !enabled);
}

function renderStockPanel(deckId) {
    const state = stockBrowser.panels[deckId];
    const content = document.querySelector(`.stock-content[data-deck="${deckId}"]`);
    const statusEl = document.querySelector(`.stock-status[data-deck="${deckId}"]`);
    if (!content || !statusEl) return;
    updateStockChrome(deckId);

    if (stockBrowser.statusLoading) {
        statusEl.className = 'stock-status loading';
        statusEl.textContent = 'Checking Pixabay connection...';
        content.innerHTML = stockSkeletons(4);
        return;
    }

    if (stockBrowser.statusError) {
        statusEl.className = 'stock-status error';
        statusEl.textContent = stockBrowser.statusError;
        if (state.mode === 'cached') {
            renderStockCache(deckId, content);
        } else {
            content.innerHTML = '<div class="stock-empty"><strong>Online stock unavailable</strong><p>The local stock-video service did not respond. Cached clips can still be used.</p><div class="stock-empty-actions"><button type="button" class="stock-action-btn" data-stock-action="retry-status">Retry</button><button type="button" class="stock-action-btn" data-stock-action="open-cached">Open cached</button></div></div>';
        }
        return;
    }

    const configured = Boolean(stockBrowser.status && stockBrowser.status.configured);
    statusEl.className = `stock-status ${configured ? 'online' : 'warning'}`;
    statusEl.textContent = configured
        ? `${String(stockBrowser.status.provider || 'Pixabay').toUpperCase()} · READY`
        : 'PIXABAY · API KEY REQUIRED';

    if (state.showConfig) {
        statusEl.className = 'stock-status warning';
        statusEl.textContent = 'PIXABAY · UPDATE API KEY';
        content.innerHTML = stockSetupMarkup(deckId);
        return;
    }

    if (state.mode === 'cached') {
        renderStockCache(deckId, content);
        return;
    }

    if (!configured) {
        content.innerHTML = stockSetupMarkup(deckId);
        return;
    }

    if (state.loading) {
        statusEl.className = 'stock-status loading';
        statusEl.textContent = `Searching Pixabay for “${state.query}”...`;
        content.innerHTML = stockSkeletons(6);
        return;
    }

    if (state.error) {
        statusEl.className = 'stock-status error';
        statusEl.textContent = state.error;
        content.innerHTML = `<div class="stock-empty"><strong>Search failed</strong><p>${escHtml(state.error)}</p><div class="stock-empty-actions"><button type="button" class="stock-action-btn" data-stock-action="retry-search">Retry search</button><button type="button" class="stock-action-btn" data-stock-action="change-key">Change API key</button></div></div>`;
        return;
    }

    if (!state.query) {
        statusEl.textContent = 'PIXABAY · SEARCH FREE STOCK VIDEO';
        content.innerHTML = stockStarterMarkup();
        return;
    }

    if (!state.results.length) {
        statusEl.textContent = `No results for “${state.query}”`;
        content.innerHTML = '<div class="stock-empty"><strong>No stock clips found</strong><p>Try a shorter or more general search.</p></div>';
        return;
    }

    statusEl.textContent = `${state.total || state.results.length} result${(state.total || state.results.length) === 1 ? '' : 's'} · page ${state.page}`;
    content.innerHTML = stockGridMarkup(deckId, state.results, false)
        + stockPagerMarkup(state);
}

function stockSetupMarkup(deckId) {
    const cached = Number(stockBrowser.status && stockBrowser.status.cacheCount) || 0;
    const replacing = Boolean(stockBrowser.status && stockBrowser.status.configured);
    const saveLabel = stockBrowser.panels[deckId].configSaving ? 'Saving...' : 'Save API key';
    return `<div class="stock-setup">
        <span class="stock-setup-mark" aria-hidden="true">KEY</span>
        <strong>${replacing ? 'Change Pixabay key' : 'Connect Pixabay'}</strong>
        <p>${replacing ? 'Enter a replacement API key. The saved key is never displayed.' : 'Enter a Pixabay API key to search free stock video.'}</p>
        <form class="stock-key-form" data-stock-config-form data-deck="${deckId}">
            <input type="password" name="apiKey" autocomplete="off" placeholder="Pixabay API key" aria-label="Pixabay API key" required>
            <button type="submit" ${stockBrowser.panels[deckId].configSaving ? 'disabled' : ''}>${saveLabel}</button>
        </form>
        <p class="stock-privacy">The key is stored only by this local app. It is never saved to localStorage, sessions, or playlists.</p>
        ${stockBrowser.panels[deckId].configError ? `<div class="stock-config-error">${escHtml(stockBrowser.panels[deckId].configError)}</div>` : ''}
        <p class="stock-config-alt">Alternative: set <code>PIXABAY_API_KEY</code> in the app environment, then restart.</p>
        <div class="stock-setup-links">
            <a href="${PIXABAY_API_URL}" target="_blank" rel="noreferrer">Get a free key / API docs</a>
            ${replacing ? '<button type="button" data-stock-action="cancel-config">Cancel</button>' : ''}
            <button type="button" data-stock-action="retry-status">Retry connection</button>
            ${cached ? '<button type="button" data-stock-action="open-cached">Use cached clips</button>' : ''}
        </div>
    </div>`;
}

function stockStarterMarkup() {
    return `<div class="stock-welcome">
        <span class="stock-welcome-mark" aria-hidden="true">STOCK</span>
        <strong>Find footage for the mix</strong>
        <p>Search Pixabay, preview details, then cache a local copy before playback.</p>
        <div class="stock-starters">${STOCK_STARTERS.map(query =>
            `<button type="button" data-stock-starter="${escHtml(query)}">${escHtml(query)}</button>`).join('')}</div>
        <div class="stock-legal">Content from <a href="https://pixabay.com/videos/" target="_blank" rel="noreferrer">Pixabay</a> · <a href="${PIXABAY_LICENSE_URL}" target="_blank" rel="noreferrer">Content License</a></div>
    </div>`;
}

function stockSkeletons(count) {
    return `<div class="stock-results stock-skeleton-grid">${Array.from({ length: count }, () =>
        '<div class="stock-card stock-skeleton"><i></i><span></span><span></span></div>').join('')}</div>`;
}

function stockGridMarkup(deckId, items, isCache) {
    return `<div class="stock-results">${items.map(item => stockCardMarkup(deckId, item, isCache)).join('')}</div>`;
}

function stockCardMarkup(deckId, asset, isCache) {
    const id = stockAssetId(asset);
    const cachedItem = stockCachedItem(asset);
    const cached = Boolean(cachedItem || asset.cached || isCache);
    const title = stockTitle(asset);
    const thumb = asset.thumbnailUrl || asset.thumbnail || '';
    const pageUrl = stockLink(asset.pageUrl);
    const creatorUrl = stockCreatorLink(asset, asset.pageUrl);
    const duration = stockDuration(asset.duration);
    const dimensions = asset.width && asset.height ? `${asset.width}×${asset.height}` : '';
    const busy = stockBrowser.busy.get(id);
    const error = stockBrowser.panels[deckId].errors[id] || '';
    const creator = stockCreator(asset);
    const thumbMarkup = thumb
        ? `<img src="${escHtml(thumb)}" alt="" loading="lazy">`
        : '<span class="stock-thumb-fallback" aria-hidden="true">VIDEO</span>';
    return `<article class="stock-card${busy ? ' is-busy' : ''}${cached ? ' is-cached' : ''}" data-stock-id="${escHtml(id)}">
        <button type="button" class="stock-thumb" data-stock-action="preview" aria-label="Preview details for ${escHtml(title)}">
            ${thumbMarkup}
            <span class="stock-badges">${duration ? `<b>${duration}</b>` : ''}${dimensions ? `<b>${escHtml(dimensions)}</b>` : ''}</span>
            ${cached ? '<span class="stock-cached-badge">CACHED</span>' : ''}
        </button>
        <div class="stock-card-body">
            <strong title="${escHtml(title)}">${escHtml(title)}</strong>
            <div class="stock-links"><a href="${pageUrl}" target="_blank" rel="noreferrer">Pixabay</a><span>·</span><a href="${creatorUrl}" target="_blank" rel="noreferrer">${escHtml(creator)}</a><span>·</span><a href="${PIXABAY_LICENSE_URL}" target="_blank" rel="noreferrer">License</a></div>
            <div class="stock-card-actions">
                ${!cached ? '<button type="button" data-stock-action="cache">Cache</button>' : ''}
                <button type="button" data-stock-action="play">Play</button>
                <button type="button" class="primary" data-stock-action="queue">+ Queue ${deckId.toUpperCase()}</button>
            </div>
            ${busy ? `<div class="stock-progress"><span></span><em>${escHtml(busy)}</em></div>` : ''}
            ${error ? `<div class="stock-card-error">${escHtml(error)}</div>` : ''}
        </div>
    </article>`;
}

function stockPagerMarkup(state) {
    if (state.total <= state.results.length && state.page <= 1) return '';
    const perPage = Math.max(1, state.pageSize || 24);
    const hasNext = state.total > state.page * perPage;
    return `<div class="stock-pager">
        <button type="button" data-stock-action="prev-page" ${state.page <= 1 ? 'disabled' : ''}>Previous</button>
        <span>Page ${state.page}</span>
        <button type="button" data-stock-action="next-page" ${hasNext ? '' : 'disabled'}>Next</button>
    </div>`;
}

function renderStockCache(deckId, content) {
    if (stockBrowser.cacheLoading) {
        content.innerHTML = stockSkeletons(4);
        return;
    }
    if (stockBrowser.cacheError) {
        content.innerHTML = `<div class="stock-empty"><strong>Could not open cache</strong><p>${escHtml(stockBrowser.cacheError)}</p><button type="button" data-stock-action="retry-cache">Retry</button></div>`;
        return;
    }
    if (!stockBrowser.cacheLoaded) {
        content.innerHTML = stockSkeletons(4);
        return;
    }
    if (!stockBrowser.cachedItems.length) {
        content.innerHTML = '<div class="stock-empty"><strong>Cache is empty</strong><p>Cache a Pixabay clip to make it show-ready.</p><button type="button" data-stock-action="open-discover">Discover clips</button></div>';
        return;
    }
    content.innerHTML = stockGridMarkup(deckId, stockBrowser.cachedItems, true);
}

async function refreshStockStatus() {
    stockBrowser.statusLoading = true;
    stockBrowser.statusError = '';
    renderStockPanel('a');
    renderStockPanel('b');
    try {
        stockBrowser.status = await stockJson('/api/stock/status');
    } catch (error) {
        stockBrowser.status = null;
        stockBrowser.statusError = error.message;
    } finally {
        stockBrowser.statusLoading = false;
        renderStockPanel('a');
        renderStockPanel('b');
    }
}

async function searchStock(deckId, query, page = 1) {
    const state = stockBrowser.panels[deckId];
    const q = String(query || '').trim();
    if (!q || !stockBrowser.status || !stockBrowser.status.configured) return;
    const token = ++state.searchToken;
    state.mode = 'discover';
    state.query = q;
    state.page = Math.max(1, Number(page) || 1);
    state.loading = true;
    state.error = '';
    document.querySelector(`.stock-search-input[data-deck="${deckId}"]`).value = q;
    setStockMode(deckId, 'discover');
    renderStockPanel(deckId);
    try {
        const data = await stockJson(`/api/stock/search?q=${encodeURIComponent(q)}&page=${state.page}`);
        if (token !== state.searchToken) return;
        state.results = Array.isArray(data.results) ? data.results : [];
        state.total = Number(data.total) || state.results.length;
        state.page = Number(data.page) || state.page;
        state.pageSize = Math.max(1, Number(data.perPage) || 24);
    } catch (error) {
        if (token !== state.searchToken) return;
        state.results = [];
        state.error = error.message;
    } finally {
        if (token === state.searchToken) {
            state.loading = false;
            renderStockPanel(deckId);
        }
    }
}

async function loadStockCache(force = false) {
    if (stockBrowser.cacheLoading || (stockBrowser.cacheLoaded && !force)) {
        renderStockPanel('a');
        renderStockPanel('b');
        return;
    }
    stockBrowser.cacheLoading = true;
    stockBrowser.cacheError = '';
    renderStockPanel('a');
    renderStockPanel('b');
    try {
        const data = await stockJson('/api/stock/cache');
        stockBrowser.cachedItems = Array.isArray(data.items) ? data.items : [];
        stockBrowser.cacheByAssetId.clear();
        stockBrowser.cachedItems.forEach(item => stockBrowser.cacheByAssetId.set(stockAssetId(item), item));
        stockBrowser.cacheLoaded = true;
    } catch (error) {
        stockBrowser.cacheError = error.message;
    } finally {
        stockBrowser.cacheLoading = false;
        renderStockPanel('a');
        renderStockPanel('b');
    }
}

async function cacheStockAsset(asset) {
    const id = stockAssetId(asset);
    const existing = stockCachedItem(asset);
    if (existing) return existing;
    if (stockBrowser.inflight.has(id)) return stockBrowser.inflight.get(id);
    const request = stockJson('/api/stock/cache', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ provider: 'pixabay', assetId: /^\d+$/.test(id) ? Number(id) : id, rendition: 'medium' }),
    }).then(data => {
        if (!data.cached || !data.item || !data.item.path) throw new Error('Pixabay clip was not cached');
        stockBrowser.cacheByAssetId.set(id, data.item);
        stockBrowser.cacheLoaded = false;
        return data.item;
    }).finally(() => stockBrowser.inflight.delete(id));
    stockBrowser.inflight.set(id, request);
    return request;
}

async function runStockAction(deckId, action, asset) {
    const state = stockBrowser.panels[deckId];
    const id = stockAssetId(asset);
    if (!asset || !id || stockBrowser.busy.has(id)) return;
    delete state.errors[id];

    if (action === 'preview') {
        openStockPreview(deckId, asset);
        return;
    }

    let item = stockCachedItem(asset);
    try {
        if (!item) {
            if (state.selected && stockAssetId(state.selected) === id) closeStockPreview(deckId);
            stockBrowser.busy.set(id, action === 'queue' ? `Caching before queue to Deck ${deckId.toUpperCase()}...` : action === 'play' ? 'Caching before playback...' : 'Caching local copy...');
            renderStockPanel('a');
            renderStockPanel('b');
            item = await cacheStockAsset(asset);
        }
        if (action === 'play') {
            const deck = stockDeck(deckId);
            deck.loadPlaylist([{ ...item }]);
            deck.play();
            updatePlayButton(deckId, true);
            closeStockPreview(deckId);
            showToast(`Playing ${item.name || stockTitle(asset)} on Deck ${deckId.toUpperCase()}`);
        } else if (action === 'queue') {
            stockDeck(deckId).addToPlaylist({ ...item });
            closeStockPreview(deckId);
            showToast(`Queued to Deck ${deckId.toUpperCase()}`);
        } else {
            showToast('Clip cached locally');
        }
        loadStockCache(true);
        refreshStockStatus();
    } catch (error) {
        state.errors[id] = error.message || 'Cache failed';
        showToast(`Stock clip failed: ${state.errors[id]}`);
    } finally {
        stockBrowser.busy.delete(id);
        renderStockPanel('a');
        renderStockPanel('b');
    }
}

function findStockAsset(deckId, id) {
    const state = stockBrowser.panels[deckId];
    return state.results.find(item => stockAssetId(item) === id)
        || stockBrowser.cachedItems.find(item => stockAssetId(item) === id)
        || stockBrowser.cacheByAssetId.get(id)
        || null;
}

function openStockPreview(deckId, asset) {
    const preview = document.querySelector(`.stock-preview[data-deck="${deckId}"]`);
    if (!preview) return;
    stockBrowser.panels[deckId].selected = asset;
    const item = stockCachedItem(asset);
    const title = stockTitle(asset);
    const thumb = asset.thumbnailUrl || asset.thumbnail || '';
    const pageUrl = stockLink(asset.pageUrl);
    const creatorUrl = stockCreatorLink(asset, asset.pageUrl);
    const media = item && item.path
        ? `<video src="/api/media?path=${encodeURIComponent(item.path)}" controls muted loop playsinline preload="metadata"></video>`
        : (thumb ? `<img src="${escHtml(thumb)}" alt="Preview image for ${escHtml(title)}">` : '<div class="stock-preview-fallback">VIDEO</div>');
    preview.innerHTML = `<div class="stock-preview-card" data-stock-id="${escHtml(stockAssetId(asset))}">
        <button type="button" class="stock-preview-close" data-stock-action="close-preview" aria-label="Close preview">×</button>
        <div class="stock-preview-media">${media}</div>
        <div class="stock-preview-copy">
            <span class="stock-preview-kicker">${item ? 'LOCAL CACHE' : 'PIXABAY PREVIEW'}</span>
            <strong>${escHtml(title)}</strong>
            <p>${stockDuration(asset.duration) || 'Video'}${asset.width && asset.height ? ` · ${asset.width}×${asset.height}` : ''} · ${escHtml(stockCreator(asset))}</p>
            <div class="stock-links"><a href="${pageUrl}" target="_blank" rel="noreferrer">Source</a><span>·</span><a href="${creatorUrl}" target="_blank" rel="noreferrer">Creator</a><span>·</span><a href="${PIXABAY_LICENSE_URL}" target="_blank" rel="noreferrer">Pixabay License</a></div>
            <div class="stock-preview-actions">
                ${item ? '' : '<button type="button" data-stock-action="cache">Cache clip</button>'}
                <button type="button" data-stock-action="play">Play on Deck ${deckId.toUpperCase()}</button>
                <button type="button" class="primary" data-stock-action="queue">+ Queue</button>
            </div>
        </div>
    </div>`;
    preview.classList.remove('hidden');
}

function closeStockPreview(deckId) {
    const preview = document.querySelector(`.stock-preview[data-deck="${deckId}"]`);
    if (!preview) return;
    const video = preview.querySelector('video');
    if (video) video.pause();
    preview.classList.add('hidden');
    preview.innerHTML = '';
    stockBrowser.panels[deckId].selected = null;
}

async function saveStockApiKey(deckId, form) {
    const state = stockBrowser.panels[deckId];
    const input = form.querySelector('input[name="apiKey"]');
    const apiKey = input ? input.value.trim() : '';
    if (!apiKey || state.configSaving) return;
    state.configSaving = true;
    state.error = '';
    state.configError = '';
    renderStockPanel(deckId);
    try {
        await stockJson('/api/stock/config', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ apiKey }),
        });
        if (input) input.value = '';
        state.configError = '';
        state.showConfig = false;
        showToast('Pixabay API key saved by the local app');
        await refreshStockStatus();
        if (stockBrowser.status && stockBrowser.status.configured) searchStock(deckId, state.query || STOCK_STARTERS[0]);
    } catch (error) {
        state.configError = error.message;
        showToast(`Could not save API key: ${error.message}`);
    } finally {
        state.configSaving = false;
        renderStockPanel(deckId);
    }
}

function handleStockClick(deckId, action, target) {
    const state = stockBrowser.panels[deckId];
    if (action === 'retry-status') return refreshStockStatus();
    if (action === 'retry-search') return searchStock(deckId, state.query, state.page);
    if (action === 'retry-cache') return loadStockCache(true);
    if (action === 'open-cached') {
        state.showConfig = false;
        setStockMode(deckId, 'cached');
        return;
    }
    if (action === 'open-discover') {
        state.showConfig = false;
        setStockMode(deckId, 'discover');
        return;
    }
    if (action === 'change-key') {
        state.mode = 'discover';
        setStockMode(deckId, 'discover');
        state.showConfig = true;
        renderStockPanel(deckId);
        return;
    }
    if (action === 'cancel-config') {
        state.showConfig = false;
        state.configError = '';
        renderStockPanel(deckId);
        return;
    }
    if (action === 'close-preview') return closeStockPreview(deckId);
    if (action === 'prev-page') return searchStock(deckId, state.query, Math.max(1, state.page - 1));
    if (action === 'next-page') return searchStock(deckId, state.query, state.page + 1);
    const holder = target.closest('[data-stock-id]');
    const id = holder && holder.dataset.stockId;
    const asset = id && findStockAsset(deckId, id);
    if (asset) runStockAction(deckId, action, asset);
}

function setupStockBrowser() {
    ['a', 'b'].forEach(deckId => {
        const form = document.querySelector(`.stock-search-form[data-deck="${deckId}"]`);
        const content = document.querySelector(`.stock-content[data-deck="${deckId}"]`);
        const preview = document.querySelector(`.stock-preview[data-deck="${deckId}"]`);
        if (form) form.addEventListener('submit', event => {
            event.preventDefault();
            const input = form.querySelector('.stock-search-input');
            searchStock(deckId, input ? input.value : '');
        });
        document.querySelectorAll(`.stock-mode[data-deck="${deckId}"]`).forEach(button =>
            button.addEventListener('click', () => {
                stockBrowser.panels[deckId].showConfig = false;
                setStockMode(deckId, button.dataset.mode);
            }));
        const configButton = document.querySelector(`.stock-config-btn[data-deck="${deckId}"]`);
        if (configButton) configButton.addEventListener('click', () => handleStockClick(deckId, 'change-key', configButton));
        if (content) {
            content.addEventListener('click', event => {
                const starter = event.target.closest('[data-stock-starter]');
                if (starter) return searchStock(deckId, starter.dataset.stockStarter);
                const action = event.target.closest('[data-stock-action]');
                if (action) handleStockClick(deckId, action.dataset.stockAction, action);
            });
            content.addEventListener('submit', event => {
                const keyForm = event.target.closest('[data-stock-config-form]');
                if (!keyForm) return;
                event.preventDefault();
                saveStockApiKey(deckId, keyForm);
            });
        }
        if (preview) preview.addEventListener('click', event => {
            if (event.target === preview) return closeStockPreview(deckId);
            const action = event.target.closest('[data-stock-action]');
            if (action) handleStockClick(deckId, action.dataset.stockAction, action);
        });
    });
    refreshStockStatus();
}


// ============================================================
// Transition Picker UI (with search filter)
// ============================================================

let transitionFilter = '';

function buildTransitionPicker() {
    const picker = document.getElementById('transition-picker');
    const currentName = document.getElementById('transition-current-name');
    const categories = window.TransitionRegistry.getByCategory();
    const activeId = (typeof mixer !== 'undefined' && mixer) ? mixer.transition : 'crossfade';

    picker.innerHTML = '';

    for (const [catName, transitions] of Object.entries(categories)) {
        const catDiv = document.createElement('div');
        catDiv.className = 'trans-category';

        const catLabel = document.createElement('div');
        catLabel.className = 'trans-cat-label';
        catLabel.textContent = catName;
        catDiv.appendChild(catLabel);

        const catItems = document.createElement('div');
        catItems.className = 'trans-cat-items';

        for (const t of transitions) {
            const btn = document.createElement('button');
            btn.className = 'trans-btn';
            btn.textContent = t.name;
            btn.dataset.transId = t.id;
            btn.title = t.name;
            if (t.id === activeId) {
                btn.classList.add('active');
                currentName.textContent = t.name;
            }

            btn.addEventListener('click', () => {
                picker.querySelectorAll('.trans-btn').forEach(b => b.classList.remove('active'));
                btn.classList.add('active');
                mixer.setTransition(t.id);
                currentName.textContent = t.name;
            });
            catItems.appendChild(btn);
        }

        catDiv.appendChild(catItems);
        picker.appendChild(catDiv);
    }

    applyTransitionFilter();
}

function applyTransitionFilter() {
    const picker = document.getElementById('transition-picker');
    const q = transitionFilter.toLowerCase();
    picker.querySelectorAll('.trans-category').forEach(cat => {
        let visible = 0;
        cat.querySelectorAll('.trans-btn').forEach(btn => {
            const match = !q || btn.textContent.toLowerCase().includes(q);
            btn.classList.toggle('hidden', !match);
            if (match) visible++;
        });
        cat.classList.toggle('hidden', visible === 0);
    });
}

function setupTransitionSearch() {
    const input = document.getElementById('transition-search');
    if (!input) return;
    input.addEventListener('input', () => {
        transitionFilter = input.value.trim();
        applyTransitionFilter();
    });
    input.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') {
            input.value = '';
            transitionFilter = '';
            applyTransitionFilter();
            input.blur();
        }
    });
}

async function loadCustomTransitions() {
    try {
        const resp = await fetch('/api/transitions');
        const data = await resp.json();
        const customList = document.getElementById('custom-list');
        customList.innerHTML = '';

        // Remove old custom transition <script> tags to prevent pile-up
        document.querySelectorAll('script[data-custom-transition]').forEach(s => s.remove());

        if (data.transitions.length === 0) {
            customList.innerHTML = '<span class="no-custom">No custom transitions. Add .js files to ~/VideoMixer/transitions/</span>';
            return;
        }

        for (const t of data.transitions) {
            try {
                const script = document.createElement('script');
                script.src = `/api/transitions/${encodeURIComponent(t.name)}?t=${Date.now()}`;
                script.dataset.customTransition = t.name;
                document.head.appendChild(script);

                const tag = document.createElement('span');
                tag.className = 'custom-tag';
                tag.textContent = t.name;
                customList.appendChild(tag);
            } catch (e) {
                console.error(`Failed to load custom transition: ${t.name}`, e);
            }
        }

        setTimeout(() => buildTransitionPicker(), 500);
    } catch (e) {
        console.error('Failed to load custom transitions:', e);
    }
}


// ============================================================
// Output Window Management
// ============================================================

function updateOutputStatus(connected) {
    const el = document.getElementById('output-status');
    if (connected) {
        el.innerHTML = '<i></i> OUTPUT LIVE';
        el.className = 'output-connected';
    } else {
        el.innerHTML = '<i></i> OUTPUT OFFLINE';
        el.className = 'output-disconnected';
    }
}

function openOutputWindow() {
    if (window.pywebview && window.pywebview.api) {
        window.pywebview.api.open_output_window();
    } else {
        window.open(
            '/output',
            'VideoMixerOutput',
            'width=1280,height=720,resizable=yes,menubar=no,toolbar=no,location=no,status=no'
        );
    }
}


// ============================================================
// BPM / Beat Sync
// ============================================================
class BPMSync {
    constructor() {
        this.bpm = 0;
        this.taps = [];
        this.syncing = false;
        this._beatTimer = null;
    }

    tap() {
        const now = performance.now();
        this.taps.push(now);

        // Keep only last 8 taps
        if (this.taps.length > 8) this.taps.shift();

        // Reset if gap > 3 seconds
        if (this.taps.length >= 2) {
            const gap = now - this.taps[this.taps.length - 2];
            if (gap > 3000) {
                this.taps = [now];
                this.bpm = 0;
                this._updateDisplay();
                return;
            }
        }

        if (this.taps.length >= 2) {
            let totalGap = 0;
            for (let i = 1; i < this.taps.length; i++) {
                totalGap += this.taps[i] - this.taps[i - 1];
            }
            const avgGap = totalGap / (this.taps.length - 1);
            this.bpm = Math.round(60000 / avgGap);
        }

        this._updateDisplay();

        // Flash the button
        const btn = document.getElementById('tap-tempo-btn');
        btn.classList.add('flash');
        setTimeout(() => btn.classList.remove('flash'), 100);
    }

    toggleSync() {
        this.syncing = !this.syncing;
        const btn = document.getElementById('beat-sync-btn');
        btn.classList.toggle('active', this.syncing);

        if (this.syncing && this.bpm > 0) {
            this._startBeatSync();
        } else {
            clearInterval(this._beatTimer);
            this._beatTimer = null;
        }
    }

    _startBeatSync() {
        clearInterval(this._beatTimer);
        if (this.bpm <= 0) return;

        const interval = 60000 / this.bpm;
        // Trigger auto-mix on every 4th beat
        let beatCount = 0;
        this._beatTimer = setInterval(() => {
            if (!this.syncing) {
                clearInterval(this._beatTimer);
                return;
            }
            beatCount++;
            if (beatCount % 4 === 0) {
                const dur = parseInt(document.getElementById('auto-mix-duration').value);
                mixer.startAutoMix(dur);
                document.getElementById('auto-mix-btn').classList.add('mixing');
            }
        }, interval);
    }

    _updateDisplay() {
        const el = document.getElementById('bpm-display');
        if (el) el.textContent = this.bpm > 0 ? `${this.bpm} BPM` : '-- BPM';
    }
}


// ============================================================
// Session Save/Load
// ============================================================

function saveSession() {
    const session = {
        version: 3,
        appVersion: VIDEO_MIXER_VERSION,
        timestamp: Date.now(),
        mixer: mixer.getSessionData(),
        deckA: deckA.getSessionData(),
        deckB: deckB.getSessionData(),
        autopilot: {
            holdTime: autopilot.holdTime,
            transitionTime: autopilot.transitionTime,
        },
        bpm: bpmSync.bpm,
        theme: getThemePref(),
    };

    const json = JSON.stringify(session, null, 2);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `video-mixer-session-${new Date().toISOString().slice(0, 10)}.json`;
    a.click();
    URL.revokeObjectURL(url);
}

function loadSession() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.json';
    input.addEventListener('change', (e) => {
        const file = e.target.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = (ev) => {
            try {
                const session = JSON.parse(ev.target.result);
                if (session.mixer) mixer.loadSessionData(session.mixer);
                if (session.deckA) deckA.loadSessionData(session.deckA);
                if (session.deckB) deckB.loadSessionData(session.deckB);
                if (session.autopilot) {
                    autopilot.setHoldTime(session.autopilot.holdTime);
                    autopilot.setTransitionTime(session.autopilot.transitionTime);
                    document.getElementById('autopilot-hold').value = session.autopilot.holdTime;
                    document.getElementById('autopilot-hold-val').textContent = session.autopilot.holdTime + 's';
                    document.getElementById('autopilot-trans').value = session.autopilot.transitionTime;
                    document.getElementById('autopilot-trans-val').textContent = session.autopilot.transitionTime + 's';
                }
                if (session.theme) {
                    setThemePref(session.theme);
                }
                // Restore UI sliders
                restoreUIFromDecks();
                buildTransitionPicker();
            } catch (err) {
                alert('Failed to load session: ' + err.message);
            }
        };
        reader.readAsText(file);
    });
    input.click();
}

function restoreUIFromDecks() {
    ['a', 'b'].forEach(id => {
        const deck = id === 'a' ? deckA : deckB;
        // Volume
        const volSlider = document.querySelector(`.vol-slider[data-deck="${id}"]`);
        if (volSlider) volSlider.value = Math.round(deck.volume * 100);
        const volVal = document.querySelector(`.vol-val[data-deck="${id}"]`);
        if (volVal) volVal.textContent = Math.round(deck.volume * 100);
        // Speed
        const speedSlider = document.querySelector(`.speed-slider[data-deck="${id}"]`);
        if (speedSlider) speedSlider.value = Math.round(deck.speed * 100);
        const speedVal = document.querySelector(`.speed-val[data-deck="${id}"]`);
        if (speedVal) speedVal.textContent = deck.speed.toFixed(1) + 'x';
        // Slide duration
        const slideSlider = document.querySelector(`.slide-dur-slider[data-deck="${id}"]`);
        if (slideSlider) slideSlider.value = deck.slideDuration;
        const slideVal = document.querySelector(`.slide-dur-val[data-deck="${id}"]`);
        if (slideVal) slideVal.textContent = deck.slideDuration === 0 ? 'OFF' : deck.slideDuration + 's';
        // Loop/shuffle buttons
        const loopBtn = document.querySelector(`.transport-btn[data-deck="${id}"][data-action="loop"]`);
        if (loopBtn) loopBtn.classList.toggle('active', deck.loop);
        const shuffleBtn = document.querySelector(`.transport-btn[data-deck="${id}"][data-action="shuffle"]`);
        if (shuffleBtn) shuffleBtn.classList.toggle('active', deck.shuffle);
        // Per-deck FX
        ['brightness', 'contrast', 'saturate', 'hue'].forEach(key => {
            const sl = document.querySelector(`.dfx-slider[data-deck="${id}"][data-fx="${key}"]`);
            if (sl) sl.value = deck.fx[key];
        });
        // Play button
        updatePlayButton(id, deck.playing);
        renderPlaylist(id);
    });
    // Master FX
    document.getElementById('fx-brightness').value = mixer.fx.brightness;
    document.getElementById('fx-contrast').value = mixer.fx.contrast;
    document.getElementById('fx-saturate').value = mixer.fx.saturate;
    document.getElementById('fx-hue').value = mixer.fx.hue;
}


// ============================================================
// Drag & Drop
// ============================================================

// Media file extensions
const MEDIA_EXTS = new Set(['.mp4', '.mov', '.webm', '.m4v', '.ogv', '.mkv',
                            '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']);
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.webm', '.m4v', '.ogv', '.mkv']);

function getExt(name) {
    const dot = name.lastIndexOf('.');
    return dot >= 0 ? name.slice(dot).toLowerCase() : '';
}

function isMediaFile(name) {
    return MEDIA_EXTS.has(getExt(name));
}

/**
 * Recursively read all files from a FileSystemDirectoryEntry.
 * Returns a Promise<File[]>.
 */
function readDirectoryRecursive(dirEntry) {
    return new Promise((resolve) => {
        const allFiles = [];
        let pending = 0;

        function readEntries(reader, done) {
            reader.readEntries((entries) => {
                if (entries.length === 0) { done(); return; }
                entries.forEach(entry => processEntry(entry));
                // readEntries may not return all at once — keep reading
                readEntries(reader, done);
            }, () => done());
        }

        function processEntry(entry) {
            pending++;
            if (entry.isFile) {
                entry.file((file) => {
                    if (isMediaFile(file.name)) {
                        allFiles.push(file);
                    }
                    pending--;
                    if (pending === 0) resolve(allFiles);
                }, () => { pending--; if (pending === 0) resolve(allFiles); });
            } else if (entry.isDirectory) {
                const reader = entry.createReader();
                readEntries(reader, () => {
                    pending--;
                    if (pending === 0) resolve(allFiles);
                });
            } else {
                pending--;
                if (pending === 0) resolve(allFiles);
            }
        }

        const reader = dirEntry.createReader();
        readEntries(reader, () => {
            if (pending === 0) resolve(allFiles);
        });
    });
}

/**
 * Process a drop event — handles both individual files and full folders.
 * Uses webkitGetAsEntry() to detect directories and recursively enumerate.
 */
async function processDroppedItems(dataTransfer) {
    const items = dataTransfer.items;
    const allFiles = [];

    if (items && items.length > 0 && items[0].webkitGetAsEntry) {
        // Modern approach: use entries API to handle folders
        const entryPromises = [];
        for (let i = 0; i < items.length; i++) {
            const entry = items[i].webkitGetAsEntry();
            if (!entry) continue;
            if (entry.isDirectory) {
                entryPromises.push(readDirectoryRecursive(entry));
            } else if (entry.isFile) {
                entryPromises.push(new Promise((resolve) => {
                    entry.file((f) => resolve(isMediaFile(f.name) ? [f] : []),
                               () => resolve([]));
                }));
            }
        }
        const results = await Promise.all(entryPromises);
        results.forEach(files => allFiles.push(...files));
    } else {
        // Fallback: plain file list (no folder support)
        Array.from(dataTransfer.files).forEach(file => {
            if (isMediaFile(file.name)) allFiles.push(file);
        });
    }

    // Sort files alphabetically by name
    allFiles.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));

    // Convert to playlist items with object URLs
    return allFiles.map(file => {
        const ext = getExt(file.name);
        return {
            name: file.name,
            path: URL.createObjectURL(file),
            type: VIDEO_EXTS.has(ext) ? 'video' : 'image',
            ext: ext,
            _isObjectUrl: true,
        };
    });
}

function setupDragDrop() {
    // Enable drop on each entire deck panel (not just preview)
    document.querySelectorAll('.deck').forEach(deckEl => {
        const deckId = deckEl.id.replace('deck-', '');
        const overlay = deckEl.querySelector('.drop-overlay');

        deckEl.addEventListener('dragover', (e) => {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'copy';
            if (overlay) overlay.classList.remove('hidden');
        });

        deckEl.addEventListener('dragleave', (e) => {
            if (!deckEl.contains(e.relatedTarget)) {
                if (overlay) overlay.classList.add('hidden');
            }
        });

        deckEl.addEventListener('drop', async (e) => {
            e.preventDefault();
            if (overlay) overlay.classList.add('hidden');
            // Internal playlist reorders are handled by the list items themselves
            if (e.target.closest('.playlist-list') || e.target.closest('.file-list')) return;

            // Show loading state
            const deck = deckId === 'a' ? deckA : deckB;
            deck.nowPlaying.textContent = 'Loading...';

            const playlist = await processDroppedItems(e.dataTransfer);

            if (playlist.length > 0) {
                deck.loadPlaylist(playlist);
                deck.play();
                updatePlayButton(deckId, true);
            } else {
                deck.nowPlaying.textContent = 'No media files found';
                setTimeout(() => {
                    if (deck.nowPlaying.textContent === 'No media files found') {
                        deck.nowPlaying.textContent = 'No media loaded';
                    }
                }, 3000);
            }
        });
    });
}


// ============================================================
// Theme — Dark / Light / Auto (follows system)
// ============================================================

const THEME_ORDER = ['dark', 'light', 'auto'];
const THEME_ICONS = { dark: '☾', light: '☀', auto: '◐' };
const _systemLight = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;

function getThemePref() {
    return localStorage.getItem('mixer-theme-pref')
        || localStorage.getItem('mixer-theme')   // legacy v1.x key
        || 'dark';
}

function setThemePref(pref) {
    if (!THEME_ORDER.includes(pref)) pref = 'dark';
    localStorage.setItem('mixer-theme-pref', pref);
    applyTheme(pref);
}

function applyTheme(pref) {
    const resolved = pref === 'auto'
        ? (_systemLight && _systemLight.matches ? 'light' : 'dark')
        : pref;
    document.documentElement.dataset.theme = resolved;
    const btn = document.getElementById('theme-toggle-btn');
    if (btn) {
        btn.textContent = THEME_ICONS[pref] || THEME_ICONS.dark;
        btn.title = `Theme: ${pref.charAt(0).toUpperCase() + pref.slice(1)} (click to cycle, D)`;
    }
}

function cycleTheme() {
    const cur = getThemePref();
    const next = THEME_ORDER[(THEME_ORDER.indexOf(cur) + 1) % THEME_ORDER.length];
    setThemePref(next);
}

function initTheme() {
    applyTheme(getThemePref());
    if (_systemLight) {
        _systemLight.addEventListener('change', () => {
            if (getThemePref() === 'auto') applyTheme('auto');
        });
    }
}

function setupThemeToggle() {
    const btn = document.getElementById('theme-toggle-btn');
    if (btn) btn.addEventListener('click', cycleTheme);
}


// ============================================================
// Resizable Layout — splitters + preview height
// ============================================================

const LAYOUT_KEY = 'mixer-layout-v2';
const LAYOUT_DEFAULTS = { growA: 1, growB: 1, centerW: 230, previewH: 150 };

function getLayout() {
    try {
        return { ...LAYOUT_DEFAULTS, ...(JSON.parse(localStorage.getItem(LAYOUT_KEY)) || {}) };
    } catch (e) { return { ...LAYOUT_DEFAULTS }; }
}

function applyLayout(layout) {
    const row = document.getElementById('decks-row');
    row.style.setProperty('--grow-a', layout.growA);
    row.style.setProperty('--grow-b', layout.growB);
    row.style.setProperty('--center-w', layout.centerW + 'px');
    row.style.setProperty('--preview-h', layout.previewH + 'px');
}

function saveLayout(layout) {
    try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)); } catch (e) {}
}

function resetLayout() {
    saveLayout({ ...LAYOUT_DEFAULTS });
    applyLayout({ ...LAYOUT_DEFAULTS });
}

function setupLayout() {
    const row = document.getElementById('decks-row');
    const center = document.getElementById('center-panel');
    const splitA = document.getElementById('split-a');
    const splitB = document.getElementById('split-b');
    let layout = getLayout();
    applyLayout(layout);

    const MIN_DECK = 260;
    const MIN_CENTER = 190;
    const MAX_CENTER = 480;

    function dragHorizontal(splitter, onMove) {
        splitter.addEventListener('pointerdown', (e) => {
            e.preventDefault();
            splitter.setPointerCapture(e.pointerId);
            splitter.classList.add('active');
            document.body.classList.add('dragging-h');

            const move = (ev) => onMove(ev.clientX);
            const up = (ev) => {
                splitter.releasePointerCapture(ev.pointerId);
                splitter.classList.remove('active');
                document.body.classList.remove('dragging-h');
                splitter.removeEventListener('pointermove', move);
                splitter.removeEventListener('pointerup', up);
                saveLayout(layout);
            };
            splitter.addEventListener('pointermove', move);
            splitter.addEventListener('pointerup', up);
        });
    }

    // Left splitter: adjusts Deck A width vs Deck B (center width unchanged)
    dragHorizontal(splitA, (x) => {
        const rect = row.getBoundingClientRect();
        const centerW = center.offsetWidth;
        const splitters = 12; // two 6px splitters
        const avail = rect.width - centerW - splitters;
        const aW = Math.max(MIN_DECK, Math.min(x - rect.left, avail - MIN_DECK));
        layout.growA = Math.round(aW);
        layout.growB = Math.round(avail - aW);
        applyLayout(layout);
    });

    // Right splitter: adjusts center panel width
    dragHorizontal(splitB, (x) => {
        const centerRect = center.getBoundingClientRect();
        const newW = Math.max(MIN_CENTER, Math.min(x - centerRect.left, MAX_CENTER));
        layout.centerW = Math.round(newW);
        applyLayout(layout);
    });

    // Preview height handles (both decks share one height)
    document.querySelectorAll('.preview-resize-handle').forEach(handle => {
        handle.addEventListener('pointerdown', (e) => {
            e.preventDefault();
            handle.setPointerCapture(e.pointerId);
            handle.classList.add('active');
            document.body.classList.add('dragging-v');
            const startY = e.clientY;
            const startH = layout.previewH;

            const move = (ev) => {
                const maxH = Math.round(window.innerHeight * 0.55);
                layout.previewH = Math.max(90, Math.min(startH + (ev.clientY - startY), maxH));
                applyLayout(layout);
            };
            const up = (ev) => {
                handle.releasePointerCapture(ev.pointerId);
                handle.classList.remove('active');
                document.body.classList.remove('dragging-v');
                handle.removeEventListener('pointermove', move);
                handle.removeEventListener('pointerup', up);
                saveLayout(layout);
            };
            handle.addEventListener('pointermove', move);
            handle.addEventListener('pointerup', up);
        });
        handle.addEventListener('dblclick', () => {
            layout.previewH = LAYOUT_DEFAULTS.previewH;
            applyLayout(layout);
            saveLayout(layout);
        });
    });

    // Double-click splitters to reset widths
    splitA.addEventListener('dblclick', () => {
        layout.growA = LAYOUT_DEFAULTS.growA;
        layout.growB = LAYOUT_DEFAULTS.growB;
        applyLayout(layout);
        saveLayout(layout);
    });
    splitB.addEventListener('dblclick', () => {
        layout.centerW = LAYOUT_DEFAULTS.centerW;
        applyLayout(layout);
        saveLayout(layout);
    });

    // Toolbar reset button
    const resetBtn = document.getElementById('reset-layout-btn');
    if (resetBtn) resetBtn.addEventListener('click', () => {
        layout = { ...LAYOUT_DEFAULTS };
        applyLayout(layout);
        saveLayout(layout);
    });
}


// ============================================================
// Autopilot
// ============================================================
class Autopilot {
    constructor() {
        this.active = false;
        this.holdTime = 8;
        this.transitionTime = 3;
        this._timer = null;
        this._liveDeck = 'a';
        this._state = 'holding';
    }

    start() {
        if (this.active) return;
        this.active = true;

        if (!deckA.playing && deckA.playlist.length > 0) {
            deckA.play();
            updatePlayButton('a', true);
        }
        if (!deckB.playing && deckB.playlist.length > 0) {
            deckB.play();
            updatePlayButton('b', true);
        }

        this._liveDeck = mixer.crossfade < 0.5 ? 'a' : 'b';
        this._state = 'holding';

        this._scheduleNext();
        this._updateUI();
    }

    stop() {
        this.active = false;
        clearTimeout(this._timer);
        this._timer = null;
        this._updateUI();
    }

    toggle() {
        if (this.active) this.stop();
        else this.start();
    }

    setHoldTime(val) { this.holdTime = val; }
    setTransitionTime(val) { this.transitionTime = val; }

    _scheduleNext() {
        if (!this.active) return;
        clearTimeout(this._timer);

        if (this._state === 'holding') {
            let holdMs = this.holdTime * 1000;
            const liveDeckObj = this._liveDeck === 'a' ? deckA : deckB;

            if (liveDeckObj.currentType === 'video' && liveDeckObj.video.duration) {
                const remaining = (liveDeckObj.video.duration - liveDeckObj.video.currentTime) * 1000;
                if (remaining > 0 && remaining < holdMs) {
                    holdMs = Math.max(remaining - this.transitionTime * 1000, 500);
                }
            }

            this._timer = setTimeout(() => {
                if (!this.active) return;
                this._startTransition();
            }, holdMs);
        }
    }

    _startTransition() {
        if (!this.active) return;
        this._state = 'transitioning';

        const nextDeck = this._liveDeck === 'a' ? 'b' : 'a';
        const nextDeckObj = nextDeck === 'a' ? deckA : deckB;

        nextDeckObj.next();
        if (!nextDeckObj.playing) {
            nextDeckObj.play();
            updatePlayButton(nextDeck, true);
        }

        const allTransitions = window.TransitionRegistry.getAll();
        const randomTrans = allTransitions[Math.floor(Math.random() * allTransitions.length)];
        mixer.setTransition(randomTrans.id);

        const picker = document.getElementById('transition-picker');
        if (picker) {
            picker.querySelectorAll('.trans-btn').forEach(b => b.classList.remove('active'));
            const activeBtn = picker.querySelector(`[data-trans-id="${randomTrans.id}"]`);
            if (activeBtn) activeBtn.classList.add('active');
        }
        const nameEl = document.getElementById('transition-current-name');
        if (nameEl) nameEl.textContent = randomTrans.name;

        mixer.startAutoMix(this.transitionTime * 1000);

        this._timer = setTimeout(() => {
            if (!this.active) return;
            this._liveDeck = nextDeck;
            this._state = 'holding';
            this._scheduleNext();
        }, this.transitionTime * 1000 + 200);
    }

    _updateUI() {
        const btn = document.getElementById('autopilot-btn');
        if (!btn) return;
        if (this.active) {
            btn.textContent = 'AUTOPILOT ON';
            btn.classList.add('active');
        } else {
            btn.textContent = 'Autopilot';
            btn.classList.remove('active');
        }
    }
}


// ============================================================
// UI Controller
// ============================================================

let deckA, deckB, mixer, browserA, browserB, autopilot, bpmSync;

document.addEventListener('DOMContentLoaded', () => {
    initTheme();

    deckA = new Deck('a');
    deckB = new Deck('b');

    mixer = new MixEngine(deckA, deckB);
    browserA = new FileBrowser('a', deckA);
    browserB = new FileBrowser('b', deckB);

    autopilot = new Autopilot();
    bpmSync = new BPMSync();

    buildTransitionPicker();
    loadCustomTransitions();
    setupStockBrowser();

    setupDeckTabs();
    setupPlaylistPanels();
    setupTransportButtons();
    setupSliders();
    setupCrossfader();
    setupAutoMix();
    setupAutopilot();
    setupFx();
    setupDeckFx();
    setupOutputResolution();
    setupPopOut();
    setupKeyboard();
    setupProgressUpdater();
    setupDragDrop();
    setupBPM();
    setupSession();
    setupFullscreen();
    setupResumeAudio();
    setupThemeToggle();
    setupTransitionSearch();
    setupLayout();
    setupRenderHealthCheck();

    renderPlaylist('a');
    renderPlaylist('b');

    if (window.pywebview && window.pywebview.api) {
        setTimeout(() => openOutputWindow(), 1500);
    }

    // Auto-save session to localStorage — only mixer state and FX, NOT playlists
    // (Saving blob: URLs is useless and grows storage indefinitely.)
    setInterval(() => {
        try {
            // Strip playlist data — paths may be blob URLs that won't survive a reload
            const dA = deckA.getSessionData();
            const dB = deckB.getSessionData();
            delete dA.playlist;
            delete dB.playlist;
            const session = {
                mixer: mixer.getSessionData(),
                deckA: dA,
                deckB: dB,
                theme: getThemePref(),
            };
            localStorage.setItem('mixer-autosave', JSON.stringify(session));
        } catch (e) {}
    }, 10000);

    // Auto-restore from localStorage
    try {
        const saved = localStorage.getItem('mixer-autosave');
        if (saved) {
            const session = JSON.parse(saved);
            if (session.mixer) mixer.loadSessionData(session.mixer);
            // Don't auto-restore playlists (paths may have changed)
        }
    } catch (e) {}
});


function setupTransportButtons() {
    document.querySelectorAll('.transport-btn').forEach(btn => {
        btn.addEventListener('click', () => {
            const deckId = btn.dataset.deck;
            const action = btn.dataset.action;
            const deck = deckId === 'a' ? deckA : deckB;

            switch (action) {
                case 'play': {
                    const playing = deck.togglePlay();
                    updatePlayButton(deckId, playing);
                    break;
                }
                case 'next': deck.next(); break;
                case 'prev': deck.prev(); break;
                case 'loop':
                    deck.loop = !deck.loop;
                    btn.classList.toggle('active', deck.loop);
                    break;
                case 'shuffle':
                    deck.shuffle = !deck.shuffle;
                    btn.classList.toggle('active', deck.shuffle);
                    break;
            }
        });
    });
}

function updatePlayButton(deckId, playing) {
    const btn = document.querySelector(`.play-btn[data-deck="${deckId}"]`);
    if (playing) {
        btn.innerHTML = '&#x23F8;';
        btn.classList.add('active');
    } else {
        btn.innerHTML = '&#x25B6;';
        btn.classList.remove('active');
    }
}

function setupSliders() {
    document.querySelectorAll('.vol-slider').forEach(slider => {
        slider.addEventListener('input', () => {
            const deck = slider.dataset.deck === 'a' ? deckA : deckB;
            deck.setVolume(slider.value / 100);
        });
    });

    document.querySelectorAll('.speed-slider').forEach(slider => {
        slider.addEventListener('input', () => {
            const deck = slider.dataset.deck === 'a' ? deckA : deckB;
            const speed = slider.value / 100;
            deck.setSpeed(speed);
            document.querySelector(`.speed-val[data-deck="${slider.dataset.deck}"]`).textContent = speed.toFixed(1) + 'x';
        });
    });

    document.querySelectorAll('.slide-dur-slider').forEach(slider => {
        slider.addEventListener('input', () => {
            const deck = slider.dataset.deck === 'a' ? deckA : deckB;
            const val = parseInt(slider.value);
            deck.setSlideDuration(val);
            const display = document.querySelector(`.slide-dur-val[data-deck="${slider.dataset.deck}"]`);
            display.textContent = val === 0 ? 'OFF' : val + 's';
            // If turning slideshow ON while an image/visual is showing, start the timer now
            if (val > 0 && (deck.currentType === 'image' || deck.currentType === 'visual') && deck.playing) {
                deck._startImageTimer();
            } else if (val === 0) {
                clearTimeout(deck.imageTimer);
            }
        });
    });

    document.querySelectorAll('.progress-slider').forEach(slider => {
        slider.addEventListener('input', () => {
            const deck = slider.dataset.deck === 'a' ? deckA : deckB;
            deck.seek(slider.value / 1000);
        });
    });
}

function setupCrossfader() {
    const cf = document.getElementById('crossfader');
    cf.addEventListener('input', () => {
        mixer.setCrossfade(cf.value / 1000);
    });
}

function setupAutoMix() {
    const durSlider = document.getElementById('auto-mix-duration');
    const durVal = document.getElementById('auto-mix-dur-val');
    durSlider.addEventListener('input', () => {
        durVal.textContent = (durSlider.value / 1000).toFixed(1) + 's';
    });

    document.getElementById('auto-mix-btn').addEventListener('click', () => {
        const dur = parseInt(document.getElementById('auto-mix-duration').value);
        mixer.startAutoMix(dur);
        document.getElementById('auto-mix-btn').classList.add('mixing');
    });
}

function setupAutopilot() {
    const btn = document.getElementById('autopilot-btn');
    if (btn) btn.addEventListener('click', () => autopilot.toggle());

    const holdSlider = document.getElementById('autopilot-hold');
    const holdVal = document.getElementById('autopilot-hold-val');
    if (holdSlider) {
        holdSlider.addEventListener('input', () => {
            autopilot.setHoldTime(parseInt(holdSlider.value));
            holdVal.textContent = holdSlider.value + 's';
        });
    }

    const transSlider = document.getElementById('autopilot-trans');
    const transVal = document.getElementById('autopilot-trans-val');
    if (transSlider) {
        transSlider.addEventListener('input', () => {
            autopilot.setTransitionTime(parseFloat(transSlider.value));
            transVal.textContent = transSlider.value + 's';
        });
    }
}

function setupFx() {
    ['brightness', 'contrast', 'saturate', 'hue'].forEach(key => {
        const el = document.getElementById(`fx-${key}`);
        el.addEventListener('input', () => {
            mixer.setFx(key, parseInt(el.value));
        });
    });

    document.getElementById('fx-reset').addEventListener('click', () => {
        mixer.resetFx();
        document.getElementById('fx-brightness').value = 100;
        document.getElementById('fx-contrast').value = 100;
        document.getElementById('fx-saturate').value = 100;
        document.getElementById('fx-hue').value = 0;
    });
}

function setupDeckFx() {
    // Toggle per-deck FX panels
    document.querySelectorAll('.deck-fx-toggle').forEach(btn => {
        btn.addEventListener('click', () => {
            const deckId = btn.dataset.deck;
            const controls = document.querySelector(`.deck-fx-controls[data-deck="${deckId}"]`);
            controls.classList.toggle('hidden');
            btn.innerHTML = controls.classList.contains('hidden') ? 'FX &#x25BC;' : 'FX &#x25B2;';
        });
    });

    // Per-deck FX sliders
    document.querySelectorAll('.dfx-slider').forEach(slider => {
        slider.addEventListener('input', () => {
            const deck = slider.dataset.deck === 'a' ? deckA : deckB;
            deck.setFx(slider.dataset.fx, parseInt(slider.value));
        });
    });

    // Reset per-deck FX
    document.querySelectorAll('.deck-fx-reset').forEach(btn => {
        btn.addEventListener('click', () => {
            const deckId = btn.dataset.deck;
            const deck = deckId === 'a' ? deckA : deckB;
            deck.resetFx();
            document.querySelectorAll(`.dfx-slider[data-deck="${deckId}"]`).forEach(sl => {
                sl.value = sl.dataset.fx === 'hue' ? 0 : 100;
            });
        });
    });
}

function setupOutputResolution() {
    document.getElementById('output-resolution').addEventListener('change', (e) => {
        const [w, h] = e.target.value.split('x').map(Number);
        mixer._setResolution(w, h);
    });
}

function setupPopOut() {
    document.getElementById('popout-btn').addEventListener('click', openOutputWindow);
    document.getElementById('load-custom-btn').addEventListener('click', () => {
        loadCustomTransitions();
    });
    const shortcutsBtn = document.getElementById('shortcuts-btn');
    if (shortcutsBtn) {
        shortcutsBtn.addEventListener('click', () => {
            document.getElementById('shortcuts-overlay').classList.toggle('hidden');
        });
    }
}

function setupBPM() {
    document.getElementById('tap-tempo-btn').addEventListener('click', () => bpmSync.tap());
    document.getElementById('beat-sync-btn').addEventListener('click', () => bpmSync.toggleSync());
}

function setupSession() {
    document.getElementById('save-session-btn').addEventListener('click', saveSession);
    document.getElementById('load-session-btn').addEventListener('click', loadSession);
}

function setupFullscreen() {
    document.getElementById('fullscreen-btn').addEventListener('click', () => {
        if (document.fullscreenElement) {
            document.exitFullscreen();
        } else {
            document.documentElement.requestFullscreen();
        }
    });
}

/**
 * Resume any suspended AudioContexts on focus / visibility change.
 * Browsers auto-suspend AudioContexts after periods of inactivity which
 * can cause audio to "die" after the app has been running a while.
 */
function setupResumeAudio() {
    const resumeAll = () => {
        [deckA, deckB].forEach(d => {
            try {
                if (d._audioCtx && d._audioCtx.state === 'suspended') {
                    d._audioCtx.resume().catch(() => {});
                }
            } catch (e) {}
        });
    };
    document.addEventListener('visibilitychange', () => {
        if (!document.hidden) resumeAll();
    });
    window.addEventListener('focus', resumeAll);
    window.addEventListener('click', resumeAll, { once: false });
    // Periodic resume guard
    setInterval(resumeAll, 30000);
}

/**
 * Detect a sluggish render loop and try to recover. If frames stop
 * coming through (e.g. after sleep/wake), kick the loop back on.
 */
function setupRenderHealthCheck() {
    let lastTick = performance.now();
    function tick() {
        lastTick = performance.now();
        requestAnimationFrame(tick);
    }
    requestAnimationFrame(tick);

    setInterval(() => {
        const lag = performance.now() - lastTick;
        if (lag > 5000) {
            // Render loop stalled — kick it
            console.warn('Render loop stalled, kicking');
            requestAnimationFrame(tick);
        }
    }, 5000);
}

function setupKeyboard() {
    const shortcutsOverlay = document.getElementById('shortcuts-overlay');
    document.getElementById('close-shortcuts').addEventListener('click', () => {
        shortcutsOverlay.classList.add('hidden');
    });

    document.addEventListener('keydown', (e) => {
        if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;

        switch (e.key.toLowerCase()) {
            case 'q': {
                const playing = deckA.togglePlay();
                updatePlayButton('a', playing);
                break;
            }
            case 'w': {
                const playing = deckB.togglePlay();
                updatePlayButton('b', playing);
                break;
            }
            case 'a': deckA.prev(); break;
            case 's':
                if (e.ctrlKey || e.metaKey) {
                    e.preventDefault();
                    saveSession();
                } else {
                    deckA.next();
                }
                break;
            case 'k': deckB.prev(); break;
            case 'l': deckB.next(); break;
            case '[': {
                e.preventDefault();
                focusDeckSearch('a');
                break;
            }
            case ']': {
                e.preventDefault();
                focusDeckSearch('b');
                break;
            }
            case 'd':
                cycleTheme();
                break;
            case 'arrowleft': {
                e.preventDefault();
                const cf = document.getElementById('crossfader');
                cf.value = Math.max(0, parseInt(cf.value) - 50);
                mixer.setCrossfade(cf.value / 1000);
                break;
            }
            case 'arrowright': {
                e.preventDefault();
                const cf = document.getElementById('crossfader');
                cf.value = Math.min(1000, parseInt(cf.value) + 50);
                mixer.setCrossfade(cf.value / 1000);
                break;
            }
            case ' ':
                e.preventDefault();
                document.getElementById('auto-mix-btn').click();
                break;
            case 'o':
                openOutputWindow();
                break;
            case 'p':
                autopilot.toggle();
                break;
            case 'f':
                if (!e.ctrlKey && !e.metaKey) {
                    if (document.fullscreenElement) document.exitFullscreen();
                    else document.documentElement.requestFullscreen();
                }
                break;
            case 't':
                bpmSync.tap();
                break;
            case '1': // Vol down deck A
                deckA.setVolume(Math.max(0, deckA.volume - 0.05));
                break;
            case '3': // Vol up deck A
                deckA.setVolume(Math.min(1, deckA.volume + 0.05));
                break;
            case '2': // Vol down deck B
                deckB.setVolume(Math.max(0, deckB.volume - 0.05));
                break;
            case '4': // Vol up deck B
                deckB.setVolume(Math.min(1, deckB.volume + 0.05));
                break;
            case '?':
                shortcutsOverlay.classList.toggle('hidden');
                break;
        }
    });
}

/** Switch a deck to the Browse tab and focus its search input. */
function focusDeckSearch(deckId) {
    const browseTab = document.querySelector(`.deck-tab[data-deck="${deckId}"][data-tab="browse"]`);
    if (browseTab && !browseTab.classList.contains('active')) browseTab.click();
    const input = document.querySelector(`.search-input[data-deck="${deckId}"]`);
    if (input) {
        input.focus();
        input.select();
    }
}

function setupProgressUpdater() {
    setInterval(() => {
        ['a', 'b'].forEach(id => {
            const deck = id === 'a' ? deckA : deckB;
            const slider = document.querySelector(`.progress-slider[data-deck="${id}"]`);
            const display = document.querySelector(`.time-display[data-deck="${id}"]`);
            slider.value = deck.getProgress() * 1000;
            display.innerHTML = deck.getTimeDisplay();
        });
    }, 250);
}
