// ============================================================
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
// ============================================================

import {
    dom, t, formatTime, showToast, resolveMediaSource,
    SVG_PLAY, SVG_PAUSE, SVG_STOP, SVG_IDLE, SVG_MUTED, SVG_UNMUTED,
    ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
    currentPosition, setCurrentPosition, isUserAdjustingVolume,
    lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
    POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
    getAuthHeaders, hasCredentials,
    togglePlayPause, nextTrack, previousTrack,
} from './core.js';
import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js';
import { loadForegroundProcess } from './foreground.js';
import { IconSelect } from './icon-select.js';

// Tab management
export let activeTab = 'player';

export function setMiniPlayerVisible(visible) {
    // On any non-player tab the mini player must stay visible regardless of scroll.
    if (activeTab !== 'player') visible = true;
    const miniPlayer = document.getElementById('mini-player');
    if (visible) {
        miniPlayer.classList.remove('hidden');
        document.body.classList.add('mini-player-visible');
    } else {
        miniPlayer.classList.add('hidden');
        document.body.classList.remove('mini-player-visible');
    }
}

export function updateTabIndicator(btn, animate = true) {
    const indicator = document.getElementById('tabIndicator');
    if (!indicator || !btn) return;
    const tabBar = document.getElementById('tabBar');
    const barRect = tabBar.getBoundingClientRect();
    const btnRect = btn.getBoundingClientRect();
    const offset = btnRect.left - barRect.left - parseFloat(getComputedStyle(tabBar).paddingLeft || 0);
    if (!animate) indicator.style.transition = 'none';
    indicator.style.width = btnRect.width + 'px';
    indicator.style.transform = `translateX(${offset}px)`;
    if (!animate) {
        indicator.offsetHeight;
        indicator.style.transition = '';
    }
}

export function switchTab(tabName) {
    activeTab = tabName;

    document.querySelectorAll('[data-tab-content]').forEach(el => {
        el.classList.remove('active');
        el.style.display = '';
    });

    const target = document.querySelector(`[data-tab-content="${tabName}"]`);
    if (target) {
        target.classList.add('active');
    }

    document.querySelectorAll('.tab-btn').forEach(btn => {
        btn.classList.remove('active');
        btn.setAttribute('aria-selected', 'false');
        btn.setAttribute('tabindex', '-1');
    });
    const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
    if (activeBtn) {
        activeBtn.classList.add('active');
        activeBtn.setAttribute('aria-selected', 'true');
        activeBtn.setAttribute('tabindex', '0');
        updateTabIndicator(activeBtn);
    }

    if (tabName === 'display') {
        loadDisplayMonitors();
        loadForegroundProcess();
    }

    localStorage.setItem('activeTab', tabName);

    if (tabName !== 'player') {
        setMiniPlayerVisible(true);
    } else {
        const playerContainer = document.querySelector('.player-container');
        const rect = playerContainer.getBoundingClientRect();
        const inView = rect.top < window.innerHeight && rect.bottom > 0;
        setMiniPlayerVisible(!inView);
    }
}

// Theme management
export function initTheme() {
    const savedTheme = localStorage.getItem('theme') || 'dark';
    setTheme(savedTheme);
}

export function setTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);

    const sunIcon = document.getElementById('theme-icon-sun');
    const moonIcon = document.getElementById('theme-icon-moon');

    if (theme === 'light') {
        sunIcon.style.display = 'none';
        moonIcon.style.display = 'block';
    } else {
        sunIcon.style.display = 'block';
        moonIcon.style.display = 'none';
    }

    const metaThemeColor = document.querySelector('meta[name="theme-color"]');
    if (metaThemeColor) {
        metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212');
    }

    updateBackgroundColors();
}

export function toggleTheme() {
    const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
    const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
    setTheme(newTheme);
}

// Accent color management
export const accentPresets = [
    { name: 'Green', color: '#1db954', hover: '#1ed760' },
    { name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
    { name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
    { name: 'Pink', color: '#ec4899', hover: '#f472b6' },
    { name: 'Orange', color: '#f97316', hover: '#fb923c' },
    { name: 'Red', color: '#ef4444', hover: '#f87171' },
    { name: 'Teal', color: '#14b8a6', hover: '#2dd4bf' },
    { name: 'Cyan', color: '#06b6d4', hover: '#22d3ee' },
    { name: 'Yellow', color: '#eab308', hover: '#facc15' },
];

export function lightenColor(hex, percent) {
    const num = parseInt(hex.replace('#', ''), 16);
    const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
    const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
    const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
    return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}

function darkenColor(hex, percent) {
    const num = parseInt(hex.replace('#', ''), 16);
    const r = Math.max(0, (num >> 16) - Math.round(255 * percent / 100));
    const g = Math.max(0, ((num >> 8) & 0xff) - Math.round(255 * percent / 100));
    const b = Math.max(0, (num & 0xff) - Math.round(255 * percent / 100));
    return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}

function hexToRgbTriple(hex) {
    const num = parseInt(hex.replace('#', ''), 16);
    const r = (num >> 16) & 0xff;
    const g = (num >> 8) & 0xff;
    const b = num & 0xff;
    return `${r}, ${g}, ${b}`;
}

export function initAccentColor() {
    const saved = localStorage.getItem('accentColor');
    if (saved) {
        const preset = accentPresets.find(p => p.color === saved);
        if (preset) {
            applyAccentColor(preset.color, preset.hover);
        } else {
            applyAccentColor(saved, lightenColor(saved, 15));
        }
    }
    renderAccentSwatches();
}

export function applyAccentColor(color, hover) {
    const root = document.documentElement.style;
    root.setProperty('--accent', color);
    root.setProperty('--accent-hover', hover);
    // Editorial palette tokens — the redesign reads these directly,
    // so the picker must drive them too (the --accent alias alone has
    // no effect once components moved off it).
    root.setProperty('--copper', color);
    root.setProperty('--copper-hi', hover);
    root.setProperty('--copper-lo', darkenColor(color, 12));
    root.setProperty('--copper-rgb', hexToRgbTriple(color));
    // --copper-glow inherits the rgba(var(--copper-rgb), 0.35) formula
    // declared in styles.css, so it picks up the new RGB automatically.
    localStorage.setItem('accentColor', color);
    const dot = document.getElementById('accentDot');
    if (dot) dot.style.background = color;
    updateBackgroundColors();
    // Refresh the cached accent in the visualizer so the gradient
    // rebuilds on its next frame instead of querying CSS every frame.
    refreshVisualizerAccent();
}

export function renderAccentSwatches() {
    const dropdown = document.getElementById('accentDropdown');
    if (!dropdown) return;
    const current = localStorage.getItem('accentColor') || '#1db954';
    const isCustom = !accentPresets.some(p => p.color === current);

    const swatches = accentPresets.map(p =>
        `<div class="accent-swatch ${p.color === current ? 'active' : ''}"
              style="background: ${p.color}"
              data-accent-color="${p.color}" data-accent-hover="${p.hover}"
              title="${p.name}"></div>`
    ).join('');

    const customRow = `
        <div class="accent-custom-row ${isCustom ? 'active' : ''}" data-accent-custom-row>
            <span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
            <span class="accent-custom-label">${t('accent.custom')}</span>
            <input type="color" id="accentCustomInput" value="${current}">
        </div>`;

    dropdown.innerHTML = swatches + customRow;

    // Wire CSP-safe handlers (script-src 'self' blocks inline on* attributes).
    dropdown.querySelectorAll('.accent-swatch[data-accent-color]').forEach(el => {
        el.addEventListener('click', () => {
            selectAccentColor(el.dataset.accentColor, el.dataset.accentHover);
        });
    });
    const customRowEl = dropdown.querySelector('[data-accent-custom-row]');
    const customInput = dropdown.querySelector('#accentCustomInput');
    if (customRowEl && customInput) {
        customRowEl.addEventListener('click', (e) => {
            // The native color popup only opens from a user-initiated click on
            // the <input>. Forward clicks on the row to the input — except when
            // the input itself was the source (avoids re-entry).
            if (e.target !== customInput) customInput.click();
        });
        customInput.addEventListener('click', (e) => e.stopPropagation());
        customInput.addEventListener('change', () => {
            selectAccentColor(customInput.value, lightenColor(customInput.value, 15));
        });
    }
}

export function selectAccentColor(color, hover) {
    applyAccentColor(color, hover);
    renderAccentSwatches();
    document.getElementById('accentDropdown').classList.remove('open');
}

export function toggleAccentPicker() {
    document.getElementById('accentDropdown').classList.toggle('open');
}

document.addEventListener('click', (e) => {
    if (!e.target.closest('.accent-picker')) {
        document.getElementById('accentDropdown')?.classList.remove('open');
    }
});

// Audio Visualizer
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
export let visualizerAvailable = false;
export function setVisualizerEnabled(value) {
    visualizerEnabled = !!value;
    localStorage.setItem('visualizerEnabled', visualizerEnabled);
}
let visualizerCanvas = null;       // Cached canvas DOM ref
let visualizerCtx = null;
let visualizerGradient = null;     // Pre-built gradient (rebuilt on accent change / resize)
let visualizerAnimFrame = null;
export let frequencyData = null;   // Latest payload from backend (int-scaled or float-scaled)
let frequencyDataVersion = 0;       // Bumped on every setFrequencyData
let lastRenderedVersion = -1;       // Last version rendered in renderVisualizerFrame
let frequenciesScale = 1.0;         // Backend scale factor (1000 → ints, 1 → floats)
export function setFrequencyData(value) {
    frequencyData = value;
    frequencyDataVersion++;
    // Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale).
    if (value && typeof value.scale === 'number' && value.scale > 0) {
        frequenciesScale = 1.0 / value.scale;
    } else {
        frequenciesScale = 1.0;
    }
}
let smoothedFrequencies = null;
const VISUALIZER_SMOOTHING = 0.15;
// Cached accent — refreshed by applyAccentColor() rather than on every frame.
let cachedAccentHex = '#1db954';
let cachedAccentRGB = '29,185,84';
function parseAccentHex(hex) {
    const h = (hex || '').trim().replace('#', '');
    if (h.length < 6) return null;
    const r = parseInt(h.slice(0, 2), 16);
    const g = parseInt(h.slice(2, 4), 16);
    const b = parseInt(h.slice(4, 6), 16);
    if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
    return `${r},${g},${b}`;
}
export function refreshVisualizerAccent() {
    const accentHex = getComputedStyle(document.documentElement)
        .getPropertyValue('--accent').trim();
    if (accentHex) {
        cachedAccentHex = accentHex;
        const rgb = parseAccentHex(accentHex);
        if (rgb) cachedAccentRGB = rgb;
    }
    // Force gradient rebuild on next frame.
    visualizerGradient = null;
}

export async function checkVisualizerAvailability() {
    try {
        const resp = await fetch('/api/media/visualizer/status', {
            headers: getAuthHeaders()
        });
        if (resp.ok) {
            const data = await resp.json();
            visualizerAvailable = data.available;
        }
    } catch (e) {
        visualizerAvailable = false;
    }
    const btn = document.getElementById('visualizerToggle');
    if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
}

export function toggleVisualizer() {
    visualizerEnabled = !visualizerEnabled;
    localStorage.setItem('visualizerEnabled', visualizerEnabled);
    applyVisualizerMode();
}

export function applyVisualizerMode() {
    const container = document.querySelector('.album-art-container');
    const btn = document.getElementById('visualizerToggle');
    if (!container) return;

    if (visualizerEnabled && visualizerAvailable) {
        container.classList.add('visualizer-active');
        if (btn) btn.classList.add('active');
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({ type: 'enable_visualizer' }));
        }
        initVisualizerCanvas();
        startVisualizerRender();
    } else {
        container.classList.remove('visualizer-active');
        if (btn) btn.classList.remove('active');
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({ type: 'disable_visualizer' }));
        }
        stopVisualizerRender();
    }

    // Sync the audio device status badge with the new capture state
    updateAudioDeviceStatus({
        running: visualizerEnabled && visualizerAvailable,
        available: visualizerAvailable
    });
}

function initVisualizerCanvas() {
    visualizerCanvas = document.getElementById('spectrogram-canvas');
    if (!visualizerCanvas) return;
    visualizerCtx = visualizerCanvas.getContext('2d');
    visualizerCanvas.width = 300;
    visualizerCanvas.height = 64;
    visualizerGradient = null;  // Force rebuild
    refreshVisualizerAccent();
}

function buildVisualizerGradient() {
    if (!visualizerCtx || !visualizerCanvas) return null;
    const h = visualizerCanvas.height;
    const grad = visualizerCtx.createLinearGradient(0, 0, 0, h);
    grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`);
    grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`);
    return grad;
}

function startVisualizerRender() {
    if (visualizerAnimFrame) return;
    // Don't even queue a frame while the tab is hidden — rAF still fires on
    // hidden tabs (throttled but not paused) and would burn CPU + battery
    // smoothing into bars no one can see. We resume on `visibilitychange`.
    if (typeof document !== 'undefined' && document.hidden) return;
    // Cache editorial spectrum bar refs once per start.
    cacheEditorialSpectrumBars();
    renderVisualizerFrame();
}

// ─── OS Media Session integration ─────────────────────────────
// Hooks the page into the system's media session so headset / lockscreen /
// Bluetooth play-pause-skip buttons drive the active track. Action handlers
// are set once and never re-registered; only the metadata + playback state
// flip when a track changes.
let _mediaSessionInitialised = false;
let _lastMediaSessionKey = '';
function syncMediaSession(status) {
    if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
    const session = navigator.mediaSession;

    if (!_mediaSessionInitialised) {
        const setHandler = (name, fn) => {
            try { session.setActionHandler(name, fn); } catch { /* unsupported action */ }
        };
        setHandler('play', () => togglePlayPause());
        setHandler('pause', () => togglePlayPause());
        setHandler('nexttrack', () => nextTrack());
        setHandler('previoustrack', () => previousTrack());
        setHandler('seekto', (ev) => { if (ev && typeof ev.seekTime === 'number') seek(ev.seekTime); });
        _mediaSessionInitialised = true;
    }

    // Track-identity key — re-build metadata only when title/artist/album change.
    const artworkSrc = status && status.album_art_url ? '/api/media/artwork' : '';
    const key = `${status.title || ''}|${status.artist || ''}|${status.album || ''}|${artworkSrc}`;
    if (key !== _lastMediaSessionKey) {
        _lastMediaSessionKey = key;
        try {
            session.metadata = new MediaMetadata({
                title: status.title || '',
                artist: status.artist || '',
                album: status.album || '',
                artwork: artworkSrc ? [{ src: artworkSrc, sizes: '512x512', type: 'image/*' }] : [],
            });
        } catch { /* MediaMetadata unsupported on very old browsers */ }
    }

    session.playbackState =
        status.state === 'playing' ? 'playing'
        : status.state === 'paused' ? 'paused'
        : 'none';

    if (typeof session.setPositionState === 'function'
        && status.duration && status.duration > 0
        && typeof status.position === 'number') {
        try {
            session.setPositionState({
                duration: status.duration,
                position: Math.min(status.position, status.duration),
                playbackRate: 1.0,
            });
        } catch { /* invalid range — ignore */ }
    }
}

// Pause / resume the visualizer with tab visibility. Idempotent: called once
// at module init below, no-op if no listener support.
if (typeof document !== 'undefined' && document.addEventListener) {
    document.addEventListener('visibilitychange', () => {
        if (document.hidden) {
            stopVisualizerRender();
        } else if (frequencyData) {
            // Only restart if a payload is live (otherwise startVisualizerRender
            // would queue a no-op rAF chain forever waiting for one).
            startVisualizerRender();
        }
    });
}

export function stopVisualizerRender() {
    if (visualizerAnimFrame) {
        cancelAnimationFrame(visualizerAnimFrame);
        visualizerAnimFrame = null;
    }
    if (visualizerCtx && visualizerCanvas) {
        visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
    }
    frequencyData = null;
    frequencyDataVersion++;     // Force next render to redraw cleared state
    lastRenderedVersion = -1;
    smoothedFrequencies = null;
    document.body.classList.remove('audio-spectrum-live');
    // Reset spectrum bar transforms so the synthetic CSS animation takes back over.
    if (editorialSpectrumBars) {
        for (let i = 0; i < editorialSpectrumBars.length; i++) {
            editorialSpectrumBars[i].style.transform = '';
        }
    }
    // Drop cached bars so next start re-queries.
    editorialSpectrumBars = null;
    editorialSpectrumLastScale = null;
}

function renderVisualizerFrame() {
    visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);

    // VU needle + position progress always tick — they read live state
    // not bound to spectrum payloads. Keeping them in this single rAF
    // is cheaper than running a second rAF loop just for the needle.
    tickVuNeedle();

    if (!frequencyData || !visualizerCtx || !visualizerCanvas) return;

    // FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes
    // at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so
    // bail when no new payload has arrived since the last draw.
    if (frequencyDataVersion === lastRenderedVersion) return;
    lastRenderedVersion = frequencyDataVersion;

    const bins = frequencyData.frequencies;
    const numBins = bins.length;
    const w = visualizerCanvas.width;
    const h = visualizerCanvas.height;
    const gap = 2;
    const barWidth = (w / numBins) - gap;
    const scale = frequenciesScale;

    if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
        smoothedFrequencies = new Float32Array(numBins);
    }
    for (let i = 0; i < numBins; i++) {
        const v = bins[i] * scale;
        smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
            + v * (1 - VISUALIZER_SMOOTHING);
    }

    if (!visualizerGradient) visualizerGradient = buildVisualizerGradient();

    visualizerCtx.clearRect(0, 0, w, h);
    visualizerCtx.fillStyle = visualizerGradient;
    visualizerCtx.beginPath();
    for (let i = 0; i < numBins; i++) {
        const barHeight = Math.max(1, smoothedFrequencies[i] * h);
        const x = i * (barWidth + gap) + gap / 2;
        const y = h - barHeight;
        visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
    }
    visualizerCtx.fill();

    // Drive the editorial .spectrum bars from the same frequency data.
    updateEditorialSpectrum(smoothedFrequencies, numBins);
}

// ─── Editorial spectrum (.spectrum bars) driven by audio ──────
// The bin distribution from the FFT is heavy on lows (the bass + mids
// dominate); a linear mapping leaves the right half of the spectrum
// looking dead. Use a logarithmic frequency-to-bar mapping plus a
// per-bar high-end gain so all bars carry visible motion.
let editorialSpectrumBars = null;            // Live HTMLCollection cached at start
let editorialSpectrumBarCount = 0;
let editorialSpectrumLastScale = null;        // Float32Array of last applied scaleY × 1000 (int rounded)
let editorialBarRanges = null;                // Pre-computed [startIdx,endIdx] pairs per bar
let editorialBarGains = null;                  // Pre-computed per-bar gain
let editorialBarRangesForBins = -1;           // numBins last used to compute ranges

function cacheEditorialSpectrumBars() {
    const root = document.querySelector('.now-playing .spectrum');
    if (!root) {
        editorialSpectrumBars = null;
        editorialSpectrumBarCount = 0;
        return;
    }
    editorialSpectrumBars = root.children;
    editorialSpectrumBarCount = editorialSpectrumBars.length;
    editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount);
    editorialSpectrumLastScale.fill(-1);
    // Pre-compute per-bar gain (constant for the lifetime of the bar list).
    editorialBarGains = new Float32Array(editorialSpectrumBarCount);
    for (let i = 0; i < editorialSpectrumBarCount; i++) {
        editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8;
    }
    editorialBarRangesForBins = -1; // Force range recompute on next call
}

function recomputeEditorialBarRanges(numBins) {
    const barCount = editorialSpectrumBarCount;
    editorialBarRanges = new Int16Array(barCount * 2);
    const lowBin = 1;
    const highBin = numBins - 1;
    const span = highBin - lowBin;
    for (let i = 0; i < barCount; i++) {
        const t0 = i / barCount;
        const t1 = (i + 1) / barCount;
        const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span));
        const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span));
        editorialBarRanges[i * 2] = startIdx;
        editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins);
    }
    editorialBarRangesForBins = numBins;
}

function updateEditorialSpectrum(bins, numBins) {
    if (!editorialSpectrumBars) cacheEditorialSpectrumBars();
    const barCount = editorialSpectrumBarCount;
    if (!barCount) return;
    if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins);
    document.body.classList.add('audio-spectrum-live');

    const ranges = editorialBarRanges;
    const gains = editorialBarGains;
    const lastScale = editorialSpectrumLastScale;
    const bars = editorialSpectrumBars;
    for (let i = 0; i < barCount; i++) {
        const startIdx = ranges[i * 2];
        const endIdx = ranges[i * 2 + 1];
        let peak = 0;
        for (let j = startIdx; j < endIdx; j++) {
            const v = bins[j];
            if (v > peak) peak = v;
        }
        // Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5).
        // Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible.
        const raw = peak * 0.65 * gains[i];
        const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw);
        // Quantize to 1/1000 — anything finer is invisible. Skip the DOM
        // write when the bar hasn't moved.
        const q = (scaleY * 1000) | 0;
        if (q === lastScale[i]) continue;
        lastScale[i] = q;
        // transform: scaleY runs on the compositor — no layout/paint.
        bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`;
    }
}

// Audio device selection
let _audioDeviceIconSelect = null;

export async function loadAudioDevices() {
    const section = document.getElementById('audioDeviceSection');
    const select = document.getElementById('audioDeviceSelect');
    if (!section || !select) return;

    try {
        const [devicesResp, statusResp] = await Promise.all([
            fetch('/api/media/visualizer/devices', {
                headers: getAuthHeaders()
            }),
            fetch('/api/media/visualizer/status', {
                headers: getAuthHeaders()
            })
        ]);

        if (!devicesResp.ok || !statusResp.ok) return;

        const devices = await devicesResp.json();
        const status = await statusResp.json();

        if (!status.available && devices.length === 0) {
            section.style.display = 'none';
            return;
        }

        section.style.display = '';

        while (select.options.length > 1) select.remove(1);
        for (const dev of devices) {
            const opt = document.createElement('option');
            opt.value = dev.name;
            opt.textContent = dev.name;
            select.appendChild(opt);
        }

        // Prefer server-reported device; fall back to the last user choice
        // saved in localStorage (so reloads persist even if the server
        // forgets between restarts).
        const savedDevice = localStorage.getItem('audioDevice') || '';
        const targetDevice = status.current_device || savedDevice;
        let pendingPushToServer = false;
        if (targetDevice) {
            for (let i = 0; i < select.options.length; i++) {
                if (select.options[i].value === targetDevice) {
                    select.selectedIndex = i;
                    break;
                }
            }
            // If the saved device wasn't on the server, push it back so
            // capture starts on the right one.
            if (!status.current_device && savedDevice) {
                pendingPushToServer = true;
            }
        }

        // Enhance with icon grid
        const audioSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5z"/></svg>';
        const items = [
            { value: '', icon: audioSvg, label: t('settings.audio.auto') },
            ...devices.map(dev => ({ value: dev.name, icon: audioSvg, label: dev.name })),
        ];
        if (_audioDeviceIconSelect) _audioDeviceIconSelect.destroy();
        _audioDeviceIconSelect = new IconSelect({
            target: select,
            items,
            columns: 1,
            horizontal: true,
            onChange: () => onAudioDeviceChanged(),
        });
        _audioDeviceIconSelect.setValue(select.value, false);

        // Sync visualizerAvailable from the fetched status so that
        // applyVisualizerMode() and the toggle button are consistent.
        visualizerAvailable = status.available;
        const btn = document.getElementById('visualizerToggle');
        if (btn) btn.style.display = visualizerAvailable ? '' : 'none';

        updateAudioDeviceStatus(status);

        // Re-subscribe the WebSocket if the user had the visualizer enabled.
        if (visualizerEnabled && visualizerAvailable) {
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.send(JSON.stringify({ type: 'enable_visualizer' }));
            }
        }

        // If the user's previously-chosen device wasn't recognized by
        // the server (e.g. server restart cleared in-memory state),
        // push it back so capture lands on the right one.
        if (pendingPushToServer && savedDevice) {
            try {
                await fetch('/api/media/visualizer/device', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
                    body: JSON.stringify({ device_name: savedDevice })
                });
            } catch (_) { /* best-effort */ }
        }
    } catch (e) {
        section.style.display = 'none';
    }
}

function updateAudioDeviceStatus(status) {
    const el = document.getElementById('audioDeviceStatus');
    if (!el) return;
    // Badge reflects local visualizer state (capture is on-demand per subscriber)
    if (visualizerEnabled && status.available) {
        el.className = 'audio-device-status active';
        el.textContent = t('settings.audio.status_active');
    } else if (status.available) {
        el.className = 'audio-device-status available';
        el.textContent = t('settings.audio.status_available');
    } else {
        el.className = 'audio-device-status unavailable';
        el.textContent = t('settings.audio.status_unavailable');
    }
}

export async function onAudioDeviceChanged() {
    const select = document.getElementById('audioDeviceSelect');
    if (!select) return;

    const deviceName = select.value || null;

    // Persist locally so reloads survive even if the server doesn't.
    if (deviceName) {
        localStorage.setItem('audioDevice', deviceName);
    } else {
        localStorage.removeItem('audioDevice');
    }

    try {
        const resp = await fetch('/api/media/visualizer/device', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
            body: JSON.stringify({ device_name: deviceName })
        });

        if (resp.ok) {
            const result = await resp.json();
            updateAudioDeviceStatus({ available: result.success, ...result });
            await checkVisualizerAvailability();
            // Picking a device is an explicit signal the user wants
            // capture: auto-enable the visualizer if it isn't already on.
            if (!visualizerEnabled && visualizerAvailable) {
                setVisualizerEnabled(true);
            }
            applyVisualizerMode();
            showToast(t('settings.audio.device_changed'), 'success');
        } else {
            showToast(t('settings.audio.device_change_failed'), 'error');
        }
    } catch (e) {
        showToast(t('settings.audio.device_change_failed'), 'error');
    }
}

// ============================================================
// UI State Updates
// ============================================================

let lastArtworkKey = null;
let currentArtworkBlobUrl = null;
let artworkFetchGen = 0;
let artworkAbort = null;
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;

export function setupProgressDrag(bar, fill) {
    // Listeners are attached on mousedown and removed on mouseup so the
    // document doesn't carry per-progress-bar move handlers for the entire
    // session (especially expensive on mobile).
    function getPercent(clientX) {
        const rect = bar.getBoundingClientRect();
        return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    }
    function updatePreview(percent) { fill.style.width = (percent * 100) + '%'; }

    function pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) {
        if (currentDuration <= 0) return;
        bar.classList.add('dragging');
        updatePreview(getPercent(getX));

        function onMove(e) { updatePreview(getPercent(getMoveX(e))); }
        function onEnd(e) {
            document.removeEventListener(moveEvent, onMove);
            document.removeEventListener(endEvent, onEnd);
            bar.classList.remove('dragging');
            const clientX = getEndX(e);
            if (clientX !== undefined) seek(getPercent(clientX) * currentDuration);
        }
        document.addEventListener(moveEvent, onMove);
        document.addEventListener(endEvent, onEnd);
    }

    bar.addEventListener('mousedown', (e) => {
        e.preventDefault();
        pointerStart(e.clientX, 'mousemove', 'mouseup',
            (ev) => ev.clientX, (ev) => ev.clientX);
    });
    bar.addEventListener('touchstart', (e) => {
        pointerStart(e.touches[0].clientX, 'touchmove', 'touchend',
            (ev) => ev.touches[0].clientX,
            (ev) => ev.changedTouches?.[0]?.clientX);
    }, { passive: true });

    bar.addEventListener('click', (e) => {
        if (currentDuration > 0) {
            seek(getPercent(e.clientX) * currentDuration);
        }
    });
}

// Replace the album-art src and replay the .is-swapping CSS animation
// so the new artwork crossfades in instead of popping. Re-toggling the
// class across rAF restarts the keyframes even if it was already on.
//
// `forceAnim=false` skips the keyframe-restart reflow when the element
// has never run the swap animation before — saves a synchronous layout
// flush on first paint. The reflow IS still required when the class
// is currently applied; otherwise the browser coalesces add+remove and
// the keyframes don't replay.
function swapArtworkSrc(imgEl, newSrc) {
    if (!imgEl) return;
    if (imgEl.src === newSrc) return;
    const wasSwapping = imgEl.classList.contains('is-swapping');
    if (wasSwapping) {
        imgEl.classList.remove('is-swapping');
        // Forced reflow restarts the keyframes — only needed when we have
        // to interrupt an in-flight animation.
        void imgEl.offsetWidth;
    }
    imgEl.src = newSrc;
    imgEl.classList.add('is-swapping');
}

// Hash of the last fully-rendered status payload — lets us skip
// updateUI altogether when the backend re-broadcasts the same state.
let lastStatusFingerprint = null;
function statusFingerprint(s) {
    return [
        s.state, s.title, s.artist, s.album, s.volume, s.muted,
        s.duration, s.source, s.album_art_url, s.position
    ].join('|');
}

export function updateUI(status) {
    setLastStatus(status);

    // Idempotence: if nothing meaningful changed, skip the entire DOM
    // pass. Track switches arrive as 1-3 status_update broadcasts in
    // quick succession; this gates the redundant ones.
    const fingerprint = statusFingerprint(status);
    if (fingerprint === lastStatusFingerprint) return;
    lastStatusFingerprint = fingerprint;

    const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
    dom.trackTitle.textContent = status.title || fallbackTitle;
    dom.artist.textContent = status.artist || '';
    dom.album.textContent = status.album || '';

    dom.miniTrackTitle.textContent = status.title || fallbackTitle;
    dom.miniArtist.textContent = status.artist || '';

    const previousState = currentState;
    setCurrentState(status.state);
    updatePlaybackState(status.state);

    const altText = status.title && status.artist
        ? `${status.artist} – ${status.title}`
        : status.title || t('player.no_media');
    dom.albumArt.alt = altText;
    dom.miniAlbumArt.alt = altText;

    const artworkSource = status.album_art_url || null;
    const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;

    if (artworkKey !== lastArtworkKey) {
        lastArtworkKey = artworkKey;
        const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
        const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
        // Cancel any in-flight artwork fetch and bump the generation so a
        // late response from a previous track cannot overwrite the new one.
        if (artworkAbort) {
            try { artworkAbort.abort(); } catch { /* ignore */ }
        }
        const myGen = ++artworkFetchGen;
        artworkAbort = new AbortController();

        if (artworkSource) {
            fetch('/api/media/artwork', {
                headers: getAuthHeaders(),
                signal: artworkAbort.signal,
            })
            .then(r => r.ok ? r.blob() : null)
            .then(blob => {
                if (!blob || myGen !== artworkFetchGen) return;
                const oldBlobUrl = currentArtworkBlobUrl;
                const url = URL.createObjectURL(blob);
                currentArtworkBlobUrl = url;
                swapArtworkSrc(dom.albumArt, url);
                if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
                if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
                syncFullscreenBloomArt(url);
                if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
            })
            .catch(err => {
                if (err && err.name === 'AbortError') return;
                console.error('Artwork fetch failed:', err);
            });
        } else {
            if (currentArtworkBlobUrl) {
                URL.revokeObjectURL(currentArtworkBlobUrl);
                currentArtworkBlobUrl = null;
            }
            swapArtworkSrc(dom.albumArt, placeholderArt);
            if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt;
            if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow;
            syncFullscreenBloomArt(placeholderGlow);
        }
    }

    if (status.duration && status.position !== null) {
        // Only redo the progress DOM when position actually changed.
        const positionChanged =
            status.duration !== currentDuration ||
            Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05;
        setCurrentDuration(status.duration);
        setCurrentPosition(status.position);
        lastPositionUpdate = Date.now();
        lastPositionValue = status.position;
        if (positionChanged) updateProgress(status.position, status.duration);
    }

    if (!isUserAdjustingVolume) {
        // Re-seed the throttling cache so a future call to setVolume() with
        // the previously-sent value still propagates after an external change.
        notifyRemoteVolume(status.volume);
        dom.volumeSlider.value = status.volume;
        dom.volumeDisplay.textContent = `${status.volume}%`;
        dom.miniVolumeSlider.value = status.volume;
        dom.miniVolumeDisplay.textContent = `${status.volume}%`;
        // VU needle: map 0-100 volume to -22deg..+22deg rotation.
        const needle = document.getElementById('vuNeedle');
        if (needle) {
            const deg = -22 + (status.volume / 100) * 44;
            needle.style.transform = `rotate(${deg}deg)`;
        }
        // Editorial VU readout: VOL XX% / OUT (SYS or MUTED)
        const vuVol = document.getElementById('vu-vol');
        if (vuVol) vuVol.textContent = `${status.volume}%`;
        const vuOut = document.getElementById('vu-out');
        if (vuOut) vuOut.textContent = status.muted ? 'MUTE' : 'SYS';
    }

    updateMuteIcon(status.muted);

    // Wire the OS Media Session so headset buttons, lockscreen controls, and
    // Bluetooth remotes drive the active media (not the WebUI tab). Cheap and
    // idempotent — re-running setActionHandler with the same fn is a no-op.
    syncMediaSession(status);

    const src = resolveMediaSource(status.source);
    dom.source.textContent = src ? src.name : t('player.unknown_source');
    dom.sourceIcon.innerHTML = src?.icon || '';

    const hasMedia = status.state !== 'idle';
    dom.btnPlayPause.disabled = !hasMedia;
    dom.btnNext.disabled = !hasMedia;
    dom.btnPrevious.disabled = !hasMedia;
    dom.miniBtnPlayPause.disabled = !hasMedia;

    if (status.state === 'playing' && previousState !== 'playing') {
        startPositionInterpolation();
    } else if (status.state !== 'playing' && previousState === 'playing') {
        stopPositionInterpolation();
    }
}

// ─── VU needle ───────────────────────────────────────────────
// The needle reflects ACTUAL audio output level (computed from the
// FFT data the visualizer feeds in). When audio capture isn't
// running, fall back to a synthetic wobble bounded by the volume
// slider position so the needle still looks alive.
//
// One unified rAF drives both the spectrum and the VU needle (see
// renderVisualizerFrame → tickVuNeedle). If the visualizer isn't
// rendering, a separate rAF takes over solely for the needle.
let vuStandaloneHandle = null;
let vuWobbleStart = 0;
let vuLevelSmoothed = 0;
let vuNeedleEl = null;          // Cached needle element
let vuVolumeSliderEl = null;     // Cached slider element
let vuLastAppliedDeg = -999;     // Skip DOM writes when angle unchanged
const VU_LEVEL_ATTACK = 0.7;   // Fast climb so the needle catches musical hits
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins

function readAudioLevel() {
    if (!frequencyData) return null;
    // Backend sends a true loudness signal (RMS-derived dB, 0..1) —
    // either as float (legacy) or scaled int (new format).
    if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale;
    if (!frequencyData.frequencies) return null;
    const bins = frequencyData.frequencies;
    if (!bins.length) return null;
    let peak = 0;
    for (let i = 1; i < bins.length; i++) {
        if (bins[i] > peak) peak = bins[i];
    }
    return Math.min(1, peak * frequenciesScale * 1.4);
}

function tickVuNeedle() {
    if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
    if (!vuNeedleEl) return;
    const audioLevel = readAudioLevel();
    let target;
    if (audioLevel != null) {
        const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
        vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
        target = -22 + vuLevelSmoothed * 44;
    } else {
        if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider');
        const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0;
        const base = -22 + (vol / 100) * 44;
        const mag = Math.max(2, Math.min(14, vol * 0.16));
        const t = (performance.now() - vuWobbleStart) / 1000;
        target = base
            + Math.sin(t * 6.3) * mag * 0.55
            + Math.sin(t * 11.7 + 1.3) * mag * 0.30
            + (Math.random() - 0.5) * mag * 0.30;
    }
    // Quantize to 0.1° — finer is invisible. Skip when unchanged.
    const q = Math.round(target * 10) / 10;
    if (q === vuLastAppliedDeg) return;
    vuLastAppliedDeg = q;
    vuNeedleEl.style.transform = `rotate(${q}deg)`;
}

function startVuWobble() {
    vuWobbleStart = performance.now();
    // If the visualizer rAF is already running, it ticks the needle for us.
    if (visualizerAnimFrame) return;
    if (vuStandaloneHandle) return;
    const standalone = () => {
        tickVuNeedle();
        // Stop ourselves once the unified visualizer loop is up.
        if (visualizerAnimFrame) {
            vuStandaloneHandle = null;
            return;
        }
        vuStandaloneHandle = requestAnimationFrame(standalone);
    };
    vuStandaloneHandle = requestAnimationFrame(standalone);
}

function stopVuWobble() {
    if (vuStandaloneHandle) {
        cancelAnimationFrame(vuStandaloneHandle);
        vuStandaloneHandle = null;
    }
    vuLevelSmoothed = 0;
    vuLastAppliedDeg = -999;
    if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
    if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)';
}

export function updatePlaybackState(state) {
    setCurrentPlayState(state);
    // Expose state to CSS so tonearm / vinyl spin can react.
    document.documentElement.dataset.playstate = state;
    // Drive the VU needle wobble — running only while playing.
    if (state === 'playing') startVuWobble();
    else stopVuWobble();
    switch(state) {
        case 'playing':
            dom.playbackState.textContent = t('state.playing');
            dom.stateIcon.innerHTML = SVG_PLAY;
            dom.playPauseIcon.innerHTML = SVG_PAUSE;
            dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
            break;
        case 'paused':
            dom.playbackState.textContent = t('state.paused');
            dom.stateIcon.innerHTML = SVG_PAUSE;
            dom.playPauseIcon.innerHTML = SVG_PLAY;
            dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
            break;
        case 'stopped':
            dom.playbackState.textContent = t('state.stopped');
            dom.stateIcon.innerHTML = SVG_STOP;
            dom.playPauseIcon.innerHTML = SVG_PLAY;
            dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
            break;
        default:
            dom.playbackState.textContent = t('state.idle');
            dom.stateIcon.innerHTML = SVG_IDLE;
            dom.playPauseIcon.innerHTML = SVG_PLAY;
            dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
    }
}

// Cache last applied progress values so we can skip DOM writes when the
// rounded second hasn't moved. Width is quantized to 0.1% — finer is
// invisible but would still trigger compositor work.
let lastProgressTenths = -1;       // 0..1000 (0.1% increments)
let lastProgressSec = -1;
let lastDurationSec = -1;
let cachedMiniBar = null;

function updateProgress(position, duration) {
    const percent = (position / duration) * 100;
    const tenths = Math.round(percent * 10);   // 0..1000
    const posRound = Math.round(position);
    const durRound = Math.round(duration);

    const widthChanged = tenths !== lastProgressTenths;
    const posChanged = posRound !== lastProgressSec;
    const durChanged = durRound !== lastDurationSec;

    if (widthChanged) {
        lastProgressTenths = tenths;
        const widthStr = (tenths / 10) + '%';
        dom.progressFill.style.width = widthStr;
        dom.miniProgressFill.style.width = widthStr;
        if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
    }

    if (posChanged) {
        lastProgressSec = posRound;
        const currentStr = formatTime(position);
        dom.currentTime.textContent = currentStr;
        if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
        dom.miniCurrentTime.textContent = currentStr;
        dom.progressBar.setAttribute('aria-valuenow', posRound);
    }

    if (durChanged) {
        lastDurationSec = durRound;
        const totalStr = formatTime(duration);
        dom.totalTime.textContent = totalStr;
        if (dom.metaLength) dom.metaLength.textContent = totalStr;
        dom.miniTotalTime.textContent = totalStr;
        dom.progressBar.dataset.duration = duration;
        dom.progressBar.setAttribute('aria-valuemax', durRound);
    }

    if (posChanged || durChanged) {
        if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar');
        if (cachedMiniBar) {
            if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound);
            if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound);
        }
    }
}

export function startPositionInterpolation() {
    if (interpolationInterval) {
        clearInterval(interpolationInterval);
    }
    interpolationInterval = setInterval(() => {
        if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
            const elapsed = (Date.now() - lastPositionUpdate) / 1000;
            const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
            updateProgress(interpolatedPosition, currentDuration);
        }
    }, POSITION_INTERPOLATION_MS);
}

export function stopPositionInterpolation() {
    if (interpolationInterval) {
        clearInterval(interpolationInterval);
        interpolationInterval = null;
    }
}

function updateMuteIcon(muted) {
    const path = muted ? SVG_MUTED : SVG_UNMUTED;
    dom.muteIcon.innerHTML = path;
    dom.miniMuteIcon.innerHTML = path;
    const vuOut = document.getElementById('vu-out');
    if (vuOut) vuOut.textContent = muted ? 'MUTE' : 'SYS';
    const cluster = document.querySelector('.now-playing .vu-cluster');
    if (cluster) cluster.classList.toggle('muted', muted);
}

// ============================================================
// Fullscreen player mode — Listening Room
//
// Two-layer model:
//   1. CSS overlay (`body.is-fullscreen-player`) — works everywhere,
//      reuses existing player markup, takes over the viewport via
//      position:fixed.
//   2. Native Fullscreen API on top — true OS-level fullscreen when
//      the user agent allows it. The CSS class is the source of truth;
//      the native API is best-effort sugar.
// ============================================================

let fsChromeIdleTimer = null;
const FS_CHROME_IDLE_MS = 2500;
let fsLastFocusedElement = null;

// Mirror the album-art onto #fs-bloom-art (the fullscreen ambient
// bloom). Called directly from the artwork-swap path — no
// MutationObserver, so we never repaint the 110px-radius blur twice.
function syncFullscreenBloomArt(url) {
    const bloom = document.getElementById('fs-bloom-art');
    if (!bloom) return;
    const target = url || (dom && dom.albumArt && dom.albumArt.src) || '';
    if (target && bloom.src !== target) bloom.src = target;
}

function showFsChrome() {
    document.body.classList.remove('fs-chrome-hidden');
    if (fsChromeIdleTimer) clearTimeout(fsChromeIdleTimer);
    if (document.body.classList.contains('is-fullscreen-player')) {
        fsChromeIdleTimer = setTimeout(() => {
            document.body.classList.add('fs-chrome-hidden');
        }, FS_CHROME_IDLE_MS);
    }
}

function onFsMouseMove() {
    showFsChrome();
}

function onFsKeyDown(e) {
    // ESC exits regardless of focus location (native API also dispatches its own,
    // but we handle the CSS-only fallback case here).
    if (e.key === 'Escape' && document.body.classList.contains('is-fullscreen-player')) {
        e.preventDefault();
        exitPlayerFullscreen();
    }
}

function onGlobalFsHotkey(e) {
    // 'F' toggles fullscreen — but never when user is typing into a field.
    if (e.key !== 'f' && e.key !== 'F') return;
    const tag = (e.target && e.target.tagName) || '';
    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
    if (e.target && e.target.isContentEditable) return;
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    e.preventDefault();
    togglePlayerFullscreen();
}

function onNativeFullscreenChange() {
    // If the user pressed ESC at the OS level or otherwise exited native
    // fullscreen, mirror the state in our CSS overlay.
    const hasNative = !!document.fullscreenElement;
    const hasOverlay = document.body.classList.contains('is-fullscreen-player');
    if (!hasNative && hasOverlay) {
        // User left native fullscreen — also drop the overlay so the UI
        // returns to its normal state in one motion.
        exitPlayerFullscreen({ skipNativeExit: true });
    }
}

function updateFullscreenButtonIcons(active) {
    const enter = document.getElementById('fullscreen-icon-enter');
    const exit = document.getElementById('fullscreen-icon-exit');
    if (enter) enter.style.display = active ? 'none' : '';
    if (exit) exit.style.display = active ? '' : 'none';
    const btn = document.getElementById('fullscreenToggle');
    if (btn) {
        btn.classList.toggle('active', active);
        btn.setAttribute('aria-pressed', active ? 'true' : 'false');
    }
}

export function enterPlayerFullscreen() {
    if (document.body.classList.contains('is-fullscreen-player')) return;

    // If we're not on the player tab, jump to it first so the markup is visible.
    if (activeTab !== 'player') switchTab('player');

    fsLastFocusedElement = document.activeElement;
    document.body.classList.add('is-fullscreen-player');
    setMiniPlayerVisible(false);
    updateFullscreenButtonIcons(true);
    // Initial mirror — subsequent swaps are pushed by updateUI directly,
    // so there is no MutationObserver in the hot path.
    syncFullscreenBloomArt();

    document.addEventListener('mousemove', onFsMouseMove, { passive: true });
    document.addEventListener('keydown', onFsKeyDown);
    showFsChrome();

    // Move keyboard focus onto the play/pause button so Space/Enter immediately
    // controls playback once the user enters the room.
    const playBtn = document.getElementById('btn-play-pause');
    if (playBtn) playBtn.focus({ preventScroll: true });

    // Best-effort native fullscreen. Failure is silent — the CSS overlay
    // already gives the user the immersive view.
    const target = document.documentElement;
    if (target.requestFullscreen && !document.fullscreenElement) {
        target.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
    }

    localStorage.setItem('fullscreenPlayerEnabled', 'true');
}

export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
    if (!document.body.classList.contains('is-fullscreen-player')) return;

    document.body.classList.remove('is-fullscreen-player', 'fs-chrome-hidden');
    updateFullscreenButtonIcons(false);

    if (fsChromeIdleTimer) {
        clearTimeout(fsChromeIdleTimer);
        fsChromeIdleTimer = null;
    }

    document.removeEventListener('mousemove', onFsMouseMove);
    document.removeEventListener('keydown', onFsKeyDown);

    if (!skipNativeExit && document.fullscreenElement && document.exitFullscreen) {
        document.exitFullscreen().catch(() => {});
    }

    // Re-evaluate mini-player visibility against scroll position.
    if (activeTab === 'player') {
        const playerContainer = document.querySelector('.player-container');
        if (playerContainer) {
            const rect = playerContainer.getBoundingClientRect();
            const inView = rect.top < window.innerHeight && rect.bottom > 0;
            setMiniPlayerVisible(!inView);
        }
    } else {
        setMiniPlayerVisible(true);
    }

    // Restore focus to whatever invoked the toggle.
    if (fsLastFocusedElement && typeof fsLastFocusedElement.focus === 'function') {
        try { fsLastFocusedElement.focus({ preventScroll: true }); } catch (_) {}
    }
    fsLastFocusedElement = null;

    localStorage.removeItem('fullscreenPlayerEnabled');
}

export function togglePlayerFullscreen() {
    if (document.body.classList.contains('is-fullscreen-player')) {
        exitPlayerFullscreen();
    } else {
        enterPlayerFullscreen();
    }
}

export function initPlayerFullscreen() {
    document.addEventListener('keydown', onGlobalFsHotkey);
    document.addEventListener('fullscreenchange', onNativeFullscreenChange);
}
