// MusicRoom - Audio visualizer
// Pure frontend Web Audio + canvas display for the shared audio element.

(function() {
  const M = window.MusicRoom;

  const MODES = new Set(["off", "bars", "wave", "radial", "pulse"]);
  const COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
  const TWO_PI = Math.PI * 2;
  const PARTICLE_COUNT = 140;

  let mode = "off";
  let shell = null;
  let canvas = null;
  let ctx = null;
  let selector = null;
  let fullscreenButton = null;
  let audioContext = null;
  let source = null;
  let gainNode = null;
  let analyser = null;
  let frequencyData = null;
  let waveformData = null;
  let animationId = 0;
  let graphUnavailable = false;
  let particles = [];
  let rememberedWidth = 0;
  let rememberedHeight = 0;
  let beatMemory = 0;
  let energyMemory = 0;
  let frame = 0;

  function normalizeMode(value) {
    return MODES.has(value) ? value : "off";
  }

  function decodeStoredValue(value) {
    if (!value) return null;
    try {
      return decodeURIComponent(value);
    } catch {
      M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
      return null;
    }
  }

  function getSavedMode() {
    const cookieMode = normalizeMode(decodeStoredValue(M.getCookieValue(M.VISUALIZER_STORAGE_KEY)));
    if (cookieMode !== "off") return cookieMode;

    try {
      const storedMode = normalizeMode(localStorage.getItem(M.VISUALIZER_STORAGE_KEY));
      if (storedMode !== "off") {
        M.setCookieValue(M.VISUALIZER_STORAGE_KEY, storedMode, COOKIE_MAX_AGE);
        return storedMode;
      }
    } catch {}

    return "off";
  }

  function rememberMode(nextMode) {
    const normalized = normalizeMode(nextMode);
    if (normalized === "off") {
      M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
      try {
        localStorage.removeItem(M.VISUALIZER_STORAGE_KEY);
      } catch {}
      return;
    }

    M.setCookieValue(M.VISUALIZER_STORAGE_KEY, normalized, COOKIE_MAX_AGE);
    try {
      localStorage.setItem(M.VISUALIZER_STORAGE_KEY, normalized);
    } catch {}
  }

  function getColors() {
    const styles = getComputedStyle(shell || document.body);
    const value = (name) => {
      const raw = styles.getPropertyValue(name).trim();
      return raw && !raw.includes("var(") ? raw : "";
    };

    return {
      accent: value("--visualizer-accent") || value("--accent") || "#44ee88",
      secondary: value("--visualizer-secondary") || value("--warning") || "#66aaff",
      background: value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)",
      muted: value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)"
    };
  }

  function resizeCanvas() {
    if (!canvas || !ctx) return;
    const rect = canvas.getBoundingClientRect();
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const width = Math.max(1, Math.round(rect.width * dpr));
    const height = Math.max(1, Math.round(rect.height * dpr));

    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    if (rememberedWidth !== canvas.clientWidth || rememberedHeight !== canvas.clientHeight) {
      rememberedWidth = canvas.clientWidth;
      rememberedHeight = canvas.clientHeight;
      seedParticles(rememberedWidth, rememberedHeight);
    }
  }

  function clearCanvas() {
    if (!canvas || !ctx) return;
    resizeCanvas();
    ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
  }

  function seedParticles(width, height) {
    particles = [];
    const safeWidth = Math.max(width, 1);
    const safeHeight = Math.max(height, 1);

    for (let i = 0; i < PARTICLE_COUNT; i++) {
      particles.push({
        x: Math.random() * safeWidth,
        y: Math.random() * safeHeight,
        px: Math.random() * safeWidth,
        py: Math.random() * safeHeight,
        vx: (Math.random() - 0.5) * 0.55,
        vy: (Math.random() - 0.5) * 0.55,
        size: 0.8 + Math.random() * 2.4,
        phase: Math.random() * TWO_PI,
        spin: (Math.random() - 0.5) * 0.04,
        lane: Math.random()
      });
    }
  }

  function initAudioGraph() {
    if (source) return true;
    if (graphUnavailable) return false;

    const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
    if (!AudioContextCtor) {
      graphUnavailable = true;
      return false;
    }

    try {
      audioContext = audioContext || new AudioContextCtor();
      analyser = audioContext.createAnalyser();
      analyser.fftSize = 2048;
      analyser.minDecibels = -96;
      analyser.maxDecibels = -10;
      analyser.smoothingTimeConstant = 0.68;

      source = audioContext.createMediaElementSource(M.audio);
      // Topology: source -> gainNode -> analyser -> destination.
      // gainNode is shared with ReplayGain (M.gainNode); analyser is a
      // transparent pass-through, so visualizer-off is unaffected.
      gainNode = audioContext.createGain();
      source.connect(gainNode);
      gainNode.connect(analyser);
      analyser.connect(audioContext.destination);
      M.gainNode = gainNode;

      frequencyData = new Uint8Array(analyser.frequencyBinCount);
      waveformData = new Uint8Array(analyser.fftSize);

      // Graph may be built after the first track arrives; re-apply current gain.
      M.applyReplayGain?.(M.currentTrack);
      return true;
    } catch (error) {
      graphUnavailable = true;
      console.warn("[Visualizer] Unable to attach audio graph", error);
      return false;
    }
  }

  // Build the audio graph (if not already) and resume the context if needed.
  // Used by ReplayGain so the gain node exists even with the visualizer off.
  M.ensureAudioGraph = function() {
    if (!initAudioGraph()) return false;
    if (audioContext && audioContext.state === "suspended") {
      audioContext.resume().catch(() => {});
    }
    return true;
  };

  function startVisualizer() {
    if (animationId || mode === "off") return;
    animationId = requestAnimationFrame(draw);
  }

  function stopVisualizer() {
    if (animationId) {
      cancelAnimationFrame(animationId);
      animationId = 0;
    }
    clearCanvas();
  }

  function average(data, start, end) {
    let total = 0;
    const first = Math.max(0, Math.min(start, data.length));
    const last = Math.max(first + 1, Math.min(end, data.length));
    for (let i = first; i < last; i++) total += data[i];
    return total / (last - first);
  }

  function bandAverage(startRatio, endRatio) {
    if (!frequencyData) return 0;
    const start = Math.floor(Math.max(0, Math.min(1, startRatio)) * frequencyData.length);
    const end = Math.floor(Math.max(0, Math.min(1, endRatio)) * frequencyData.length);
    return average(frequencyData, start, Math.max(start + 1, end)) / 255;
  }

  function bandValue(index, count) {
    if (!frequencyData) return 0;
    const a = Math.pow(index / count, 1.75);
    const b = Math.pow((index + 1) / count, 1.75);
    const start = Math.floor(a * frequencyData.length);
    const end = Math.max(start + 2, Math.floor(b * frequencyData.length));
    const raw = average(frequencyData, start, end) / 255;
    return Math.pow(raw, 0.72);
  }

  function readAudioState(timestamp, hasLiveGraph) {
    if (hasLiveGraph) {
      analyser.getByteFrequencyData(frequencyData);
      analyser.getByteTimeDomainData(waveformData);
    }

    const bass = hasLiveGraph ? bandAverage(0.005, 0.06) : 0.55 + Math.sin(timestamp / 310) * 0.25;
    const mids = hasLiveGraph ? bandAverage(0.06, 0.36) : 0.45 + Math.sin(timestamp / 470 + 1.4) * 0.22;
    const treble = hasLiveGraph ? bandAverage(0.36, 0.92) : 0.38 + Math.sin(timestamp / 230 + 2.2) * 0.18;
    const energy = Math.max(0, Math.min(1, bass * 0.42 + mids * 0.36 + treble * 0.22));

    beatMemory = Math.max(beatMemory * 0.9, bass);
    energyMemory = energyMemory * 0.82 + energy * 0.18;

    return {
      bass,
      mids,
      treble,
      energy,
      beat: beatMemory,
      smooth: energyMemory,
      live: hasLiveGraph,
      time: timestamp / 1000
    };
  }

  function fillWithTrails(width, height, colors, state) {
    ctx.globalCompositeOperation = "source-over";
    ctx.globalAlpha = state.live ? 0.24 : 0.42;
    ctx.fillStyle = colors.background;
    ctx.fillRect(0, 0, width, height);
    ctx.globalAlpha = 1;

    const glow = ctx.createRadialGradient(
      width * (0.48 + Math.sin(state.time * 0.4) * 0.08),
      height * (0.52 + Math.cos(state.time * 0.33) * 0.14),
      1,
      width * 0.5,
      height * 0.5,
      Math.max(width, height) * (0.4 + state.energy * 0.25)
    );
    glow.addColorStop(0, colors.secondary);
    glow.addColorStop(0.38, colors.accent);
    glow.addColorStop(1, "transparent");
    ctx.globalAlpha = 0.08 + state.energy * 0.1;
    ctx.fillStyle = glow;
    ctx.fillRect(0, 0, width, height);
    ctx.globalAlpha = 1;
  }

  function drawGrid(width, height, colors, state) {
    const spacing = Math.max(12, Math.min(34, width / 34));
    const offset = (state.time * (30 + state.treble * 80)) % spacing;

    ctx.save();
    ctx.globalAlpha = 0.11 + state.energy * 0.08;
    ctx.strokeStyle = colors.muted;
    ctx.lineWidth = 1;

    for (let x = -spacing + offset; x < width + spacing; x += spacing) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x + Math.sin(state.time + x * 0.01) * 18, height);
      ctx.stroke();
    }

    for (let y = height + spacing - offset; y > -spacing; y -= spacing) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(width, y + Math.cos(state.time + y * 0.02) * 8);
      ctx.stroke();
    }

    ctx.globalAlpha = 0.09 + state.beat * 0.08;
    ctx.strokeStyle = colors.secondary;
    for (let y = 0; y < height; y += 4) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(width, y);
      ctx.stroke();
    }

    ctx.restore();
  }

  function drawParticles(width, height, colors, state, intensity = 1) {
    if (!particles.length) seedParticles(width, height);

    ctx.save();
    ctx.globalCompositeOperation = "lighter";

    for (const p of particles) {
      p.px = p.x;
      p.py = p.y;

      const wave = Math.sin(state.time * 2.2 + p.phase) * 0.65 + Math.cos(state.time + p.lane * 8) * 0.35;
      const speed = (0.6 + state.energy * 3.2) * intensity;
      p.vx += Math.cos(p.phase + state.time * (0.8 + p.lane)) * 0.015 * speed;
      p.vy += Math.sin(p.phase * 1.7 + state.time * 1.15) * 0.018 * speed;
      p.x += p.vx * speed + wave * state.treble * 1.8;
      p.y += p.vy * speed + Math.sin(state.time * 1.4 + p.x * 0.015) * state.bass * 1.6;
      p.vx *= 0.965;
      p.vy *= 0.965;
      p.phase += p.spin + state.energy * 0.012;

      if (p.x < -20) p.x = width + 20;
      if (p.x > width + 20) p.x = -20;
      if (p.y < -20) p.y = height + 20;
      if (p.y > height + 20) p.y = -20;

      const useSecondary = p.lane > 0.52;
      ctx.strokeStyle = useSecondary ? colors.secondary : colors.accent;
      ctx.fillStyle = useSecondary ? colors.secondary : colors.accent;
      ctx.globalAlpha = 0.14 + state.energy * 0.34;
      ctx.lineWidth = Math.max(0.8, p.size * (0.5 + state.beat * 1.4));
      ctx.beginPath();
      ctx.moveTo(p.px, p.py);
      ctx.lineTo(p.x, p.y);
      ctx.stroke();

      if (frame % 2 === 0 || state.energy > 0.52) {
        ctx.globalAlpha = 0.16 + state.treble * 0.45;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size * (0.7 + state.beat * 1.8), 0, TWO_PI);
        ctx.fill();
      }
    }

    ctx.restore();
    ctx.globalAlpha = 1;
    ctx.globalCompositeOperation = "source-over";
  }

  function drawSpectrumRibbon(width, height, colors, state, flipped = false) {
    const count = Math.max(44, Math.min(150, Math.floor(width / 7)));
    const step = width / count;
    const baseY = flipped ? height * 0.74 : height * 0.26;
    const direction = flipped ? -1 : 1;

    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    ctx.strokeStyle = flipped ? colors.secondary : colors.accent;
    ctx.fillStyle = flipped ? colors.secondary : colors.accent;
    ctx.lineWidth = 1.4 + state.energy * 2.4;
    ctx.globalAlpha = 0.33 + state.energy * 0.25;
    ctx.beginPath();

    for (let i = 0; i <= count; i++) {
      const value = state.live ? bandValue(i % count, count) : 0.35 + Math.sin(state.time * 3 + i * 0.27) * 0.26;
      const x = i * step;
      const y = baseY + direction * (value * height * 0.3 + Math.sin(state.time * 2 + i * 0.18) * height * 0.035);
      if (i === 0) ctx.moveTo(x, baseY);
      ctx.lineTo(x, y);
    }
    ctx.lineTo(width, baseY);
    ctx.closePath();
    ctx.fill();

    ctx.globalAlpha = 0.78;
    ctx.stroke();
    ctx.restore();
  }

  function drawWaveformRibbon(width, height, colors, state, amplitude = 0.32, offset = 0, color = colors.accent) {
    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.2 + state.energy * 3.2;
    ctx.globalAlpha = 0.4 + state.energy * 0.36;
    ctx.beginPath();

    const samples = state.live && waveformData ? waveformData.length : 720;
    const step = Math.max(2, Math.floor(samples / Math.max(90, Math.min(240, width / 4))));
    let first = true;

    for (let i = 0; i < samples; i += step) {
      const t = i / Math.max(1, samples - 1);
      const raw = state.live ? (waveformData[i] - 128) / 128 : Math.sin(t * TWO_PI * 5 + state.time * 3 + offset) * 0.55 + Math.sin(t * TWO_PI * 17 - state.time * 2) * 0.15;
      const x = t * width;
      const y = height * 0.5 + raw * height * amplitude + Math.sin(t * TWO_PI * 2 + state.time + offset) * state.bass * height * 0.08;
      if (first) {
        ctx.moveTo(x, y);
        first = false;
      } else {
        ctx.lineTo(x, y);
      }
    }

    ctx.stroke();
    ctx.restore();
  }

  function drawBars(width, height, colors, state) {
    drawGrid(width, height, colors, state);
    drawSpectrumRibbon(width, height, colors, state, false);
    drawSpectrumRibbon(width, height, colors, state, true);

    const count = Math.max(56, Math.min(180, Math.floor(width / 5)));
    const gap = Math.max(1, Math.min(3, width / 380));
    const barWidth = Math.max(2, width / count - gap);
    const centerY = height / 2;
    const gradient = ctx.createLinearGradient(0, height, 0, 0);
    gradient.addColorStop(0, colors.secondary);
    gradient.addColorStop(0.5, colors.accent);
    gradient.addColorStop(1, colors.secondary);

    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    ctx.shadowColor = colors.accent;
    ctx.shadowBlur = 10 + state.beat * 20;
    ctx.fillStyle = gradient;

    for (let i = 0; i < count; i++) {
      const value = state.live ? bandValue(i, count) : 0.28 + Math.sin(state.time * 4 + i * 0.21) * 0.25 + Math.sin(state.time * 2.1 + i * 0.07) * 0.16;
      const pulse = Math.max(0, Math.min(1, value + state.beat * 0.18));
      const barHeight = Math.max(2, pulse * height * 0.47);
      const x = i * (barWidth + gap);
      const skew = Math.sin(state.time * 3 + i * 0.25) * state.treble * 5;

      ctx.globalAlpha = 0.45 + pulse * 0.55;
      ctx.fillRect(x + skew, centerY - barHeight, barWidth, barHeight);
      ctx.fillRect(x - skew, centerY, barWidth, barHeight);

      if (i % 3 === 0) {
        ctx.globalAlpha = 0.35 + state.treble * 0.4;
        ctx.fillRect(x, centerY - barHeight - 4 - state.treble * 9, barWidth, 2);
        ctx.fillRect(x, centerY + barHeight + 2 + state.treble * 9, barWidth, 2);
      }
    }

    ctx.restore();
    drawWaveformRibbon(width, height, colors, state, 0.18, 0, colors.secondary);
    drawWaveformRibbon(width, height, colors, state, 0.1, Math.PI, colors.accent);
    drawParticles(width, height, colors, state, 1.1);
  }

  function drawWave(width, height, colors, state) {
    drawGrid(width, height, colors, state);
    drawSpectrumRibbon(width, height, colors, state, false);

    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    for (let layer = 0; layer < 6; layer++) {
      const color = layer % 2 ? colors.secondary : colors.accent;
      drawWaveformRibbon(width, height, colors, state, 0.13 + layer * 0.045, layer * 0.9 + state.time * 0.4, color);
    }
    ctx.restore();

    const nodes = 34;
    ctx.save();
    ctx.globalCompositeOperation = "lighter";
    for (let i = 0; i < nodes; i++) {
      const t = i / (nodes - 1);
      const sampleIndex = state.live ? Math.floor(t * (waveformData.length - 1)) : 0;
      const wave = state.live ? (waveformData[sampleIndex] - 128) / 128 : Math.sin(t * TWO_PI * 8 + state.time * 3);
      const value = state.live ? bandValue(i, nodes) : Math.abs(wave);
      const x = t * width;
      const y = height * 0.5 + wave * height * 0.28;
      const radius = 2 + value * 13 + state.beat * 6;

      ctx.globalAlpha = 0.14 + value * 0.55;
      ctx.fillStyle = i % 2 ? colors.secondary : colors.accent;
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, TWO_PI);
      ctx.fill();
    }
    ctx.restore();

    drawParticles(width, height, colors, state, 1.35);
  }

  function drawRadial(width, height, colors, state) {
    drawGrid(width, height, colors, state);

    const cx = width / 2;
    const cy = height / 2;
    const radius = Math.max(14, Math.min(width, height) * (0.14 + state.bass * 0.08));
    const spokes = 192;

    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(state.time * (0.18 + state.treble * 0.22));
    ctx.globalCompositeOperation = "lighter";
    ctx.lineCap = "round";

    for (let ring = 0; ring < 3; ring++) {
      const ringRadius = radius + ring * Math.min(width, height) * 0.105 + state.beat * 18;
      ctx.globalAlpha = 0.18 + ring * 0.08;
      ctx.strokeStyle = ring % 2 ? colors.secondary : colors.accent;
      ctx.lineWidth = 1 + state.energy * 2.4;
      ctx.beginPath();
      for (let i = 0; i <= spokes; i++) {
        const value = state.live ? bandValue(i % spokes, spokes) : 0.34 + Math.sin(state.time * 4 + i * 0.16 + ring) * 0.24;
        const angle = i / spokes * TWO_PI;
        const waveRadius = ringRadius + value * Math.min(width, height) * (0.08 + ring * 0.035);
        const x = Math.cos(angle) * waveRadius;
        const y = Math.sin(angle) * waveRadius;
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();
    }

    for (let i = 0; i < spokes; i++) {
      const value = state.live ? bandValue(i, spokes) : 0.28 + Math.sin(state.time * 6 + i * 0.23) * 0.24;
      const angle = i / spokes * TWO_PI;
      const start = radius * (0.68 + Math.sin(state.time + i) * 0.04);
      const end = start + value * Math.min(width, height) * 0.42 + state.beat * 28;

      ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
      ctx.globalAlpha = 0.16 + value * 0.72;
      ctx.lineWidth = 0.9 + value * 3.8;
      ctx.beginPath();
      ctx.moveTo(Math.cos(angle) * start, Math.sin(angle) * start);
      ctx.lineTo(Math.cos(angle) * end, Math.sin(angle) * end);
      ctx.stroke();
    }

    ctx.globalAlpha = 0.22 + state.beat * 0.26;
    ctx.fillStyle = colors.accent;
    ctx.beginPath();
    ctx.arc(0, 0, radius * (0.55 + state.beat * 0.45), 0, TWO_PI);
    ctx.fill();
    ctx.restore();

    drawParticles(width, height, colors, state, 1.2);
  }

  function drawPulse(width, height, colors, state) {
    drawGrid(width, height, colors, state);

    const cx = width / 2;
    const cy = height / 2;
    const maxRadius = Math.hypot(width, height) * 0.55;

    ctx.save();
    ctx.globalCompositeOperation = "lighter";

    for (let i = 0; i < 10; i++) {
      const phase = (state.time * (0.18 + state.energy * 0.24) + i / 10) % 1;
      const radius = phase * maxRadius;
      ctx.globalAlpha = (1 - phase) * (0.1 + state.beat * 0.22);
      ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
      ctx.lineWidth = 1 + state.energy * 5;
      ctx.beginPath();
      ctx.arc(cx, cy, radius, 0, TWO_PI);
      ctx.stroke();
    }

    const rays = 96;
    for (let i = 0; i < rays; i++) {
      const value = state.live ? bandValue(i, rays) : 0.32 + Math.sin(state.time * 7 + i * 0.31) * 0.25;
      const angle = i / rays * TWO_PI + state.time * 0.25;
      const length = Math.min(width, height) * (0.16 + value * 0.7 + state.beat * 0.22);
      const wobble = Math.sin(state.time * 3 + i * 0.4) * state.treble * 18;
      ctx.globalAlpha = 0.15 + value * 0.58;
      ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
      ctx.lineWidth = 0.9 + value * 3.5;
      ctx.beginPath();
      ctx.moveTo(cx + Math.cos(angle) * 8, cy + Math.sin(angle) * 8);
      ctx.lineTo(cx + Math.cos(angle) * (length + wobble), cy + Math.sin(angle) * (length - wobble));
      ctx.stroke();
    }

    const coreRadius = Math.max(8, Math.min(width, height) * (0.1 + state.beat * 0.18));
    ctx.globalAlpha = 0.3 + state.beat * 0.45;
    ctx.fillStyle = colors.secondary;
    ctx.beginPath();
    ctx.arc(cx, cy, coreRadius * 2.2, 0, TWO_PI);
    ctx.fill();

    ctx.globalAlpha = 0.8;
    ctx.fillStyle = colors.accent;
    ctx.beginPath();
    ctx.arc(cx, cy, coreRadius, 0, TWO_PI);
    ctx.fill();
    ctx.restore();

    drawWaveformRibbon(width, height, colors, state, 0.2, state.time, colors.secondary);
    drawParticles(width, height, colors, state, 1.6);
  }

  function drawIdle(width, height, colors, timestamp) {
    const state = readAudioState(timestamp, false);
    fillWithTrails(width, height, colors, state);
    drawGrid(width, height, colors, state);

    if (mode === "radial") drawRadial(width, height, colors, state);
    else if (mode === "pulse") drawPulse(width, height, colors, state);
    else if (mode === "wave") drawWave(width, height, colors, state);
    else drawBars(width, height, colors, state);
  }

  function draw(timestamp) {
    animationId = requestAnimationFrame(draw);
    if (!canvas || !ctx || mode === "off") return;

    frame++;
    resizeCanvas();
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const colors = getColors();
    const hasLiveGraph = analyser
      && frequencyData
      && waveformData
      && audioContext?.state === "running"
      && !M.audio.paused
      && !!M.audio.src;

    if (!hasLiveGraph) {
      drawIdle(width, height, colors, timestamp);
      return;
    }

    const state = readAudioState(timestamp, true);
    fillWithTrails(width, height, colors, state);

    if (mode === "wave") drawWave(width, height, colors, state);
    else if (mode === "radial") drawRadial(width, height, colors, state);
    else if (mode === "pulse") drawPulse(width, height, colors, state);
    else drawBars(width, height, colors, state);
  }

  function isFullscreen() {
    return document.fullscreenElement === shell
      || document.webkitFullscreenElement === shell
      || shell?.classList.contains("is-fullscreen");
  }

  function updateFullscreenButton() {
    if (!fullscreenButton || !shell) return;
    const active = isFullscreen();
    shell.classList.toggle("is-fullscreen", active);
    fullscreenButton.textContent = active ? "×" : "⛶";
    fullscreenButton.title = active ? "Exit fullscreen visualizer" : "Fullscreen visualizer";
    fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
  }

  async function toggleFullscreen() {
    if (!shell) return;
    if (mode === "off") M.applyVisualizer("bars");

    try {
      if (document.fullscreenElement || document.webkitFullscreenElement || shell.classList.contains("is-fullscreen")) {
        if (document.fullscreenElement && document.exitFullscreen) await document.exitFullscreen();
        else if (document.webkitFullscreenElement && document.webkitExitFullscreen) await document.webkitExitFullscreen();
        else shell.classList.remove("is-fullscreen");
      } else if (shell.requestFullscreen) {
        await shell.requestFullscreen();
      } else if (shell.webkitRequestFullscreen) {
        await shell.webkitRequestFullscreen();
      } else {
        shell.classList.add("is-fullscreen");
      }
    } catch (error) {
      shell.classList.toggle("is-fullscreen");
      console.warn("[Visualizer] Fullscreen request failed", error);
    }

    updateFullscreenButton();
    resizeCanvas();
    M.resumeVisualizer?.();
  }

  M.applyVisualizer = function(nextMode, remember = true) {
    mode = normalizeMode(nextMode);
    if (selector) selector.value = mode;
    if (shell) shell.classList.toggle("hidden", mode === "off");
    document.getElementById("player-content")?.classList.toggle("visualizer-active", mode !== "off");
    if (remember) rememberMode(mode);

    if (mode === "off") {
      stopVisualizer();
    } else {
      resizeCanvas();
      startVisualizer();
    }
  };

  M.resumeVisualizer = async function() {
    if (mode === "off") return false;
    if (!initAudioGraph()) return false;

    try {
      if (audioContext.state === "suspended") {
        await audioContext.resume();
      }
      startVisualizer();
      return audioContext.state === "running";
    } catch (error) {
      console.warn("[Visualizer] Unable to resume audio context", error);
      return false;
    }
  };

  M.visualizer = {
    getMode: () => mode,
    hasGraph: () => !!source,
    isRunning: () => audioContext?.state === "running",
    isFullscreen,
    toggleFullscreen
  };

  document.addEventListener("DOMContentLoaded", () => {
    shell = document.getElementById("visualizer-shell");
    canvas = document.getElementById("visualizer-canvas");
    selector = document.getElementById("visualizer-select");
    fullscreenButton = document.getElementById("btn-visualizer-fullscreen");
    ctx = canvas?.getContext("2d") || null;

    M.applyVisualizer(getSavedMode(), false);
    updateFullscreenButton();

    if (selector) {
      selector.onchange = () => {
        M.applyVisualizer(selector.value);
        M.resumeVisualizer();
      };
    }

    if (fullscreenButton) {
      fullscreenButton.onclick = (event) => {
        event.preventDefault();
        event.stopPropagation();
        toggleFullscreen();
      };
    }

    if (window.ResizeObserver && shell) {
      const observer = new ResizeObserver(resizeCanvas);
      observer.observe(shell);
    }

    window.addEventListener("resize", resizeCanvas);
  });

  document.addEventListener("fullscreenchange", () => {
    updateFullscreenButton();
    resizeCanvas();
  });
  document.addEventListener("webkitfullscreenchange", () => {
    updateFullscreenButton();
    resizeCanvas();
  });

  document.addEventListener("pointerdown", () => {
    if (mode !== "off") M.resumeVisualizer();
  }, { passive: true });

  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape" && shell?.classList.contains("is-fullscreen") && !document.fullscreenElement && !document.webkitFullscreenElement) {
      shell.classList.remove("is-fullscreen");
      updateFullscreenButton();
      resizeCanvas();
      return;
    }
    if (mode !== "off") M.resumeVisualizer();
  });

  M.audio.addEventListener("play", () => {
    // Resume context whenever the graph exists (visualizer or replaygain path),
    // since routing through it requires a running context to produce sound.
    if (audioContext && audioContext.state === "suspended") {
      audioContext.resume().catch(() => {});
    }
    if (mode !== "off") {
      startVisualizer();
    }
  });

  M.audio.addEventListener("pause", () => {
    if (mode !== "off") startVisualizer();
  });
})();
