# Audio UI Rewrite Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Rewrite PulseJT's audio system with pub/sub state management, static waveform scrub track, unified playback bar, and inline dock volume slider — fixing all 7 known stability bugs.

**Architecture:** Self-contained audio module within the IIFE in `JS/bundle.js`. Five components (AudioBus, AudioState, WaveformRenderer, UnifiedBar, DockAudioControl) communicate through a shared event bus. All old audio/timeline code is removed and replaced.

**Tech Stack:** Vanilla ES5 JavaScript (CEP compatibility), HTML5 Canvas, OfflineAudioContext, CSS custom properties for theming.

---

## Task 1: Add AudioBus (Event Emitter)

**Files:**
- Modify: `JS/bundle.js:1146-1169` (replace old `state` object with new audio system section)

**Step 1: Delete the old unused state object**

In `JS/bundle.js`, replace lines 1146-1169 (the `// 3. STATE` section with the unused `state` object) with:

```javascript
  // ============================================================================
  // 3. AUDIO SYSTEM (v4.0)
  // ============================================================================

  // --- AudioBus: lightweight pub/sub event emitter ---
  var AudioBus = (function() {
    var listeners = {};

    return {
      on: function(event, callback) {
        if (!listeners[event]) listeners[event] = [];
        listeners[event].push(callback);
      },
      off: function(event, callback) {
        if (!listeners[event]) return;
        listeners[event] = listeners[event].filter(function(cb) { return cb !== callback; });
      },
      emit: function(event, data) {
        if (!listeners[event]) return;
        var cbs = listeners[event].slice();
        for (var i = 0; i < cbs.length; i++) {
          try { cbs[i](data || {}); } catch (e) { console.error('[AudioBus] Error in ' + event + ' handler:', e); }
        }
      }
    };
  })();
```

**Step 2: Verify no errors on panel load**

Open After Effects, open PulseJT panel. Open Chrome DevTools at `localhost:8092`. Check console for `Starting PulseJT v3.5` message with no new errors.

**Step 3: Commit**

```bash
git add JS/bundle.js
git commit -m "feat(audio): add AudioBus pub/sub event emitter, remove unused state object"
```

---

## Task 2: Add AudioState (Centralized State)

**Files:**
- Modify: `JS/bundle.js` (add directly after AudioBus, before `// 4. STORAGE KEYS`)

**Step 1: Add AudioState module**

Insert after the AudioBus code, before line ~1175 (`// 4. STORAGE KEYS`):

```javascript
  // --- AudioState: centralized audio state with localStorage sync ---
  var AudioState = (function() {
    var _video = null;
    var _retryTimer = null;
    var _videoListeners = [];

    var defaults = {
      volume: 1,
      muted: false,
      playing: false,
      currentTime: 0,
      duration: 0,
      speed: 1,
      loop: true,
      autoplay: true
    };

    // Keys that persist to localStorage
    var storageMap = {
      volume: 'gifPreview.volume',
      muted: 'gifPreview.audioMuted',
      speed: 'gifPreview.playbackSpeed',
      loop: 'gifPreview.loopEnabled',
      autoplay: 'gifPreview.autoplay'
    };

    var values = {};

    function init() {
      // Load persisted values with explicit defaults
      values.volume = parseFloat(localStorage.getItem(storageMap.volume)) || defaults.volume;
      if (isNaN(values.volume) || values.volume < 0 || values.volume > 1) values.volume = defaults.volume;

      var mutedRaw = localStorage.getItem(storageMap.muted);
      values.muted = mutedRaw === 'true';

      values.speed = parseFloat(localStorage.getItem(storageMap.speed)) || defaults.speed;
      values.loop = localStorage.getItem(storageMap.loop) !== 'false';
      values.autoplay = localStorage.getItem(storageMap.autoplay) !== 'false';

      // Transient state (not persisted)
      values.playing = defaults.playing;
      values.currentTime = defaults.currentTime;
      values.duration = defaults.duration;

      console.log('[AudioState] Initialized:', values);
    }

    function get(key) {
      return values[key];
    }

    function set(key, value) {
      if (values[key] === value) return; // idempotent
      values[key] = value;

      // Persist if applicable
      if (storageMap[key]) {
        localStorage.setItem(storageMap[key], value.toString());
      }

      // Emit change event
      var eventData = {};
      eventData[key] = value;
      AudioBus.emit(key + ':change', eventData);

      // Apply to video element if attached
      if (_video) {
        if (key === 'volume') _video.volume = value;
        if (key === 'muted') _video.muted = value;
        if (key === 'speed') _video.playbackRate = value;
        if (key === 'loop') _video.loop = value;
      }
    }

    function _addVideoListener(event, handler) {
      _video.addEventListener(event, handler);
      _videoListeners.push({ event: event, handler: handler });
    }

    function attachVideo(videoEl) {
      detachVideo();
      _video = videoEl;

      // Apply current state to video
      _video.loop = values.loop;
      _video.volume = values.volume;
      _video.muted = true; // Browser autoplay compliance — always start muted

      // Wire video events to state
      _addVideoListener('timeupdate', function() {
        values.currentTime = _video.currentTime;
        values.duration = _video.duration || 0;
        AudioBus.emit('playback:timeupdate', {
          current: _video.currentTime,
          duration: _video.duration || 0
        });
      });

      _addVideoListener('play', function() {
        values.playing = true;
        AudioBus.emit('playback:play', {});
      });

      _addVideoListener('pause', function() {
        values.playing = false;
        AudioBus.emit('playback:pause', {});
      });

      _addVideoListener('ended', function() {
        values.playing = false;
        AudioBus.emit('playback:ended', {});
      });

      _addVideoListener('loadedmetadata', function() {
        values.duration = _video.duration || 0;
        AudioBus.emit('playback:timeupdate', {
          current: _video.currentTime,
          duration: _video.duration || 0
        });
      });

      // Autoplay with mute-compliance
      if (values.autoplay) {
        _video.muted = true;
        _tryPlay(3);
      }

      AudioBus.emit('video:loaded', { video: _video });
      console.log('[AudioState] Video attached');
    }

    function _tryPlay(attempts) {
      if (!_video) return;
      var targetVideo = _video; // Capture for stale check

      var playPromise = _video.play();
      if (playPromise !== undefined) {
        playPromise.then(function() {
          // Stale video check — if user loaded a new file during retry, abort
          if (targetVideo !== _video) return;

          // Restore user preferences now that playback started
          _video.muted = values.muted;
          _video.volume = values.volume;
          _video.playbackRate = values.speed;
        }).catch(function(error) {
          console.warn('[AudioState] Autoplay attempt blocked:', error.message);
          if (attempts > 0 && targetVideo === _video) {
            _retryTimer = setTimeout(function() { _tryPlay(attempts - 1); }, 200);
          }
        });
      }
    }

    function detachVideo() {
      if (_retryTimer) {
        clearTimeout(_retryTimer);
        _retryTimer = null;
      }

      if (_video) {
        // Remove all listeners we added
        for (var i = 0; i < _videoListeners.length; i++) {
          _video.removeEventListener(_videoListeners[i].event, _videoListeners[i].handler);
        }
        _videoListeners = [];
        AudioBus.emit('video:removed', {});
        console.log('[AudioState] Video detached');
      }

      _video = null;
      values.playing = false;
      values.currentTime = 0;
      values.duration = 0;
    }

    function getVideo() {
      return _video;
    }

    return {
      init: init,
      get: get,
      set: set,
      attachVideo: attachVideo,
      detachVideo: detachVideo,
      getVideo: getVideo
    };
  })();
```

**Step 2: Initialize AudioState in startApp**

In `JS/bundle.js`, inside `startApp()` (~line 6555), add `AudioState.init();` right after `initStorage();` (line 6562). Do this in BOTH the success and catch paths:

```javascript
        initStorage();
        AudioState.init(); // ADD THIS LINE
        console.log('✅ Storage initialized');
```

And in the catch block (~line 6597):
```javascript
        initStorage();
        AudioState.init(); // ADD THIS LINE
        restoreState();
```

**Step 3: Verify no errors**

Reload panel. Console should show `[AudioState] Initialized:` with the state values. No other errors.

**Step 4: Commit**

```bash
git add JS/bundle.js
git commit -m "feat(audio): add AudioState centralized state with localStorage sync"
```

---

## Task 3: Add WaveformRenderer

**Files:**
- Modify: `JS/bundle.js` (add after AudioState, before `// 4. STORAGE KEYS`)

**Step 1: Add WaveformRenderer module**

Insert after AudioState, before `// 4. STORAGE KEYS`:

```javascript
  // --- WaveformRenderer: generates static waveform from video audio ---
  var WaveformRenderer = (function() {
    var _amplitudes = null; // Float32Array of ~200 peak values
    var _barCount = 200;

    function generate(videoSrc) {
      _amplitudes = null;

      // Fetch the video as an ArrayBuffer to decode audio
      return fetch(videoSrc)
        .then(function(response) { return response.arrayBuffer(); })
        .then(function(buffer) {
          var audioCtx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 44100, 44100);
          return audioCtx.decodeAudioData(buffer);
        })
        .then(function(audioBuffer) {
          var rawData = audioBuffer.getChannelData(0);
          var samplesPerBar = Math.floor(rawData.length / _barCount);
          _amplitudes = new Float32Array(_barCount);

          for (var i = 0; i < _barCount; i++) {
            var start = i * samplesPerBar;
            var peak = 0;
            for (var j = start; j < start + samplesPerBar && j < rawData.length; j++) {
              var abs = Math.abs(rawData[j]);
              if (abs > peak) peak = abs;
            }
            _amplitudes[i] = peak;
          }

          // Normalize to 0-1
          var maxPeak = 0;
          for (var k = 0; k < _amplitudes.length; k++) {
            if (_amplitudes[k] > maxPeak) maxPeak = _amplitudes[k];
          }
          if (maxPeak > 0) {
            for (var m = 0; m < _amplitudes.length; m++) {
              _amplitudes[m] = _amplitudes[m] / maxPeak;
            }
          }

          console.log('[WaveformRenderer] Generated waveform with ' + _barCount + ' bars');
        })
        .catch(function(err) {
          console.warn('[WaveformRenderer] Decode failed (no audio track?), using fallback:', err.message);
          _amplitudes = null;
        });
    }

    function draw(canvas, progress) {
      if (!canvas) return;
      var ctx = canvas.getContext('2d');
      var w = canvas.width;
      var h = canvas.height;

      ctx.clearRect(0, 0, w, h);

      // Get theme colors from CSS custom properties
      var style = getComputedStyle(document.documentElement);
      var primaryColor = style.getPropertyValue('--theme-primary').trim() || '#4a9eff';
      var primaryLight = style.getPropertyValue('--theme-primary-light').trim() || '#6bb3ff';

      if (!_amplitudes) {
        // Fallback: flat progress bar (2px tall, centered)
        var barY = (h / 2) - 1;

        // Base track
        ctx.fillStyle = 'rgba(255,255,255,0.12)';
        ctx.fillRect(0, barY, w, 2);

        // Progress fill
        if (progress > 0) {
          ctx.fillStyle = primaryColor;
          ctx.fillRect(0, barY, w * progress, 2);
        }
        return;
      }

      var barWidth = w / _amplitudes.length;
      var minBarHeight = 2;
      var maxBarHeight = h * 0.85;
      var gap = Math.max(1, barWidth * 0.15);
      var fillBarWidth = barWidth - gap;
      var progressX = w * progress;

      for (var i = 0; i < _amplitudes.length; i++) {
        var barHeight = minBarHeight + (_amplitudes[i] * (maxBarHeight - minBarHeight));
        var x = i * barWidth;
        var y = (h - barHeight) / 2;

        if (x + fillBarWidth <= progressX) {
          // Fully in progress region
          ctx.fillStyle = primaryColor;
        } else if (x < progressX) {
          // Partially in progress region
          ctx.fillStyle = primaryColor;
          ctx.fillRect(x, y, progressX - x, barHeight);
          ctx.fillStyle = 'rgba(255,255,255,0.12)';
          ctx.fillRect(progressX, y, (x + fillBarWidth) - progressX, barHeight);
          continue;
        } else {
          // Not yet reached
          ctx.fillStyle = 'rgba(255,255,255,0.12)';
        }

        ctx.fillRect(x, y, fillBarWidth, barHeight);
      }
    }

    function clear() {
      _amplitudes = null;
    }

    function hasWaveform() {
      return _amplitudes !== null;
    }

    return {
      generate: generate,
      draw: draw,
      clear: clear,
      hasWaveform: hasWaveform
    };
  })();
```

**Step 2: Verify no errors**

Reload panel. No new console errors. WaveformRenderer is defined but not yet called.

**Step 3: Commit**

```bash
git add JS/bundle.js
git commit -m "feat(audio): add WaveformRenderer with OfflineAudioContext and flat fallback"
```

---

## Task 4: Add UnifiedBar HTML + CSS

**Files:**
- Modify: `index.html:742-754` (replace old timeline HTML)
- Modify: `style.css:1916-2023` (replace old timeline CSS)

**Step 1: Replace timeline HTML in index.html**

Replace lines 742-754 (the old `#timelineBar` block) with:

```html
    <!-- Unified Audio Bar (waveform + scrub + play/pause + time) -->
    <div class="unified-bar" id="unifiedBar" style="display: none;">
      <button class="unified-bar-play" id="unifiedBarPlay" title="Play/Pause">
        <span class="unified-bar-play-icon" id="unifiedBarPlayIcon">&#9654;</span>
      </button>
      <div class="unified-bar-waveform" id="unifiedBarWaveform">
        <canvas id="unifiedBarCanvas"></canvas>
        <div class="unified-bar-hover-time" id="unifiedBarHoverTime"></div>
      </div>
      <div class="unified-bar-time" id="unifiedBarTime">0:00 / 0:00</div>
    </div>
```

**Step 2: Replace timeline CSS in style.css**

Replace lines 1916-2023 (everything from `.timeline-bar {` through `.app.uiHidden .timeline-bar { display: none !important; }`) with:

```css
/* ===== UNIFIED AUDIO BAR ===== */
.unified-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 10px;
  background: rgba(0, 0, 0, 0.4);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 10px;
  margin: 0 8px;
  flex-shrink: 0;
  z-index: 5;
  transition: opacity 0.2s ease;
}

.unified-bar-play {
  width: 24px;
  height: 24px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.06);
  color: var(--theme-primary);
  font-size: 10px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  transition: all 0.2s ease;
  padding: 0;
  line-height: 1;
}

.unified-bar-play:hover {
  background: rgba(255, 255, 255, 0.12);
  border-color: var(--theme-primary-soft);
  box-shadow: 0 0 8px var(--theme-glow);
}

.unified-bar-waveform {
  flex: 1;
  height: 28px;
  position: relative;
  cursor: pointer;
  border-radius: 4px;
  overflow: hidden;
}

.unified-bar-waveform canvas {
  width: 100%;
  height: 100%;
  display: block;
}

.unified-bar-hover-time {
  position: absolute;
  top: -24px;
  left: 0;
  background: rgba(0, 0, 0, 0.75);
  color: var(--theme-text-primary);
  font-size: 10px;
  padding: 2px 6px;
  border-radius: 4px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  transform: translateX(-50%);
}

.unified-bar-waveform:hover .unified-bar-hover-time {
  opacity: 1;
}

.unified-bar-time {
  font-size: 11px;
  color: var(--theme-text-secondary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  min-width: 70px;
  text-align: right;
  flex-shrink: 0;
}

/* Hide unified bar in fullscreen */
.app.uiHidden .unified-bar {
  display: none !important;
}
```

**Step 3: Verify no visual regressions**

Reload panel. The old timeline should no longer appear. The new bar HTML exists but is hidden (`display: none`). No console errors.

**Step 4: Commit**

```bash
git add index.html style.css
git commit -m "feat(audio): replace timeline HTML/CSS with unified bar structure"
```

---

## Task 5: Add UnifiedBar JavaScript

**Files:**
- Modify: `JS/bundle.js` (add after WaveformRenderer, before `// 4. STORAGE KEYS`)

**Step 1: Add UnifiedBar module**

Insert after WaveformRenderer:

```javascript
  // --- UnifiedBar: merged timeline + waveform + play/pause + time ---
  var UnifiedBar = (function() {
    var _bar, _playBtn, _playIcon, _waveformArea, _canvas, _hoverTime, _timeDisplay;
    var _isDragging = false;
    var _resizeObserver = null;

    function init() {
      _bar = document.getElementById('unifiedBar');
      _playBtn = document.getElementById('unifiedBarPlay');
      _playIcon = document.getElementById('unifiedBarPlayIcon');
      _waveformArea = document.getElementById('unifiedBarWaveform');
      _canvas = document.getElementById('unifiedBarCanvas');
      _hoverTime = document.getElementById('unifiedBarHoverTime');
      _timeDisplay = document.getElementById('unifiedBarTime');

      if (!_bar) return;

      // Play/Pause button
      if (_playBtn) {
        _playBtn.addEventListener('click', function() {
          var video = AudioState.getVideo();
          if (!video) return;
          if (video.paused) { video.play(); } else { video.pause(); }
        });
      }

      // Seek by click/drag on waveform
      if (_waveformArea) {
        _waveformArea.addEventListener('mousedown', function(e) {
          _isDragging = true;
          _seekFromEvent(e);

          function onMove(ev) { _seekFromEvent(ev); }
          function onUp() {
            _isDragging = false;
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('mouseup', onUp);
          }
          document.addEventListener('mousemove', onMove);
          document.addEventListener('mouseup', onUp);
        });

        // Hover time tooltip
        _waveformArea.addEventListener('mousemove', function(e) {
          if (!_hoverTime) return;
          var video = AudioState.getVideo();
          if (!video || !video.duration) return;

          var rect = _waveformArea.getBoundingClientRect();
          var x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
          var pct = x / rect.width;
          var hoverSec = pct * video.duration;

          _hoverTime.textContent = _formatTime(hoverSec);
          _hoverTime.style.left = x + 'px';
        });
      }

      // Resize canvas to match container
      if (_canvas && _waveformArea) {
        _sizeCanvas();
        if (typeof ResizeObserver !== 'undefined') {
          _resizeObserver = new ResizeObserver(function() {
            _sizeCanvas();
            // Redraw with current progress
            var video = AudioState.getVideo();
            var progress = 0;
            if (video && video.duration) {
              progress = video.currentTime / video.duration;
            }
            WaveformRenderer.draw(_canvas, progress);
          });
          _resizeObserver.observe(_waveformArea);
        }
      }

      // Subscribe to AudioBus events
      AudioBus.on('playback:play', function() { _updatePlayIcon(false); });
      AudioBus.on('playback:pause', function() { _updatePlayIcon(true); });
      AudioBus.on('playback:ended', function() { _updatePlayIcon(true); });

      AudioBus.on('playback:timeupdate', function(data) {
        if (_isDragging) return;
        // Update time display
        if (_timeDisplay) {
          _timeDisplay.textContent = _formatTime(data.current) + ' / ' + _formatTime(data.duration);
        }
        // Redraw waveform with progress
        if (_canvas && data.duration > 0) {
          WaveformRenderer.draw(_canvas, data.current / data.duration);
        }
      });

      AudioBus.on('video:loaded', function(data) {
        var enabled = localStorage.getItem(STORAGE_KEYS.TIMELINE_ENABLED) !== 'false';
        if (enabled && _bar) {
          _bar.style.display = 'flex';
          _updatePlayIcon(data.video.paused);

          // Generate waveform from video source
          WaveformRenderer.generate(data.video.src).then(function() {
            _sizeCanvas();
            WaveformRenderer.draw(_canvas, 0);
          });
        }
      });

      AudioBus.on('video:removed', function() {
        if (_bar) _bar.style.display = 'none';
        if (_timeDisplay) _timeDisplay.textContent = '0:00 / 0:00';
        WaveformRenderer.clear();
        if (_canvas) {
          var ctx = _canvas.getContext('2d');
          ctx.clearRect(0, 0, _canvas.width, _canvas.height);
        }
      });

      console.log('[UnifiedBar] Initialized');
    }

    function _sizeCanvas() {
      if (!_canvas || !_waveformArea) return;
      var rect = _waveformArea.getBoundingClientRect();
      var dpr = window.devicePixelRatio || 1;
      _canvas.width = rect.width * dpr;
      _canvas.height = rect.height * dpr;
      _canvas.getContext('2d').scale(dpr, dpr);
    }

    function _seekFromEvent(e) {
      var video = AudioState.getVideo();
      if (!video || !video.duration || !_waveformArea) return;
      var rect = _waveformArea.getBoundingClientRect();
      var x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
      var pct = x / rect.width;
      video.currentTime = pct * video.duration;

      // Immediate visual feedback
      if (_canvas) {
        WaveformRenderer.draw(_canvas, pct);
      }
      if (_timeDisplay) {
        _timeDisplay.textContent = _formatTime(video.currentTime) + ' / ' + _formatTime(video.duration);
      }
    }

    function _updatePlayIcon(paused) {
      if (!_playIcon) return;
      _playIcon.textContent = paused ? '\u25B6' : '\u23F8';
    }

    function _formatTime(sec) {
      if (!sec || isNaN(sec)) return '0:00';
      var m = Math.floor(sec / 60);
      var s = Math.floor(sec % 60);
      return m + ':' + (s < 10 ? '0' : '') + s;
    }

    return { init: init };
  })();
```

**Step 2: Verify no errors**

Reload panel. Console should show `[UnifiedBar] Initialized`. Bar still hidden (no video loaded yet).

**Step 3: Commit**

```bash
git add JS/bundle.js
git commit -m "feat(audio): add UnifiedBar JS with waveform scrubbing and event subscriptions"
```

---

## Task 6: Replace Dock Audio Controls (HTML + CSS + JS)

**Files:**
- Modify: `index.html:784-794` (replace old dock audio wrapper)
- Modify: `style.css:638-805` (replace old volume popup CSS)
- Modify: `JS/bundle.js` (add DockAudioControl module after UnifiedBar)

**Step 1: Replace dock audio HTML in index.html**

Replace lines 784-794 (the old `#audioControlWrapper` block) with:

```html
          <!-- Dock Audio: mute icon + inline slider -->
          <div class="dock-audio" id="dockAudio" style="display: none;">
            <button class="dockBtn" id="dockAudioMute" title="Toggle Audio">
              <span class="ico" id="dockAudioIcon">&#128263;</span>
            </button>
            <div class="dock-audio-slider-wrap">
              <input type="range" class="dock-audio-slider" id="dockAudioSlider" min="0" max="100" value="100">
            </div>
          </div>
```

**Step 2: Replace volume popup CSS in style.css**

Replace lines 638-805 (everything from `/* ===== AUDIO CONTROL WITH VOLUME POPUP ===== */` through the last `.volume-popup-value` rule, up to but NOT including `/* ===== ENHANCED DOCK BUTTONS ===== */`) with:

```css
/* ===== DOCK AUDIO CONTROL ===== */
.dock-audio {
  display: flex;
  align-items: center;
  gap: 0;
  position: relative;
}

.dock-audio-slider-wrap {
  max-width: 0;
  overflow: hidden;
  transition: max-width 0.25s ease, opacity 0.2s ease;
  opacity: 0;
  display: flex;
  align-items: center;
}

.dock-audio:hover .dock-audio-slider-wrap,
.dock-audio.slider-visible .dock-audio-slider-wrap {
  max-width: 80px;
  opacity: 1;
}

.dock-audio-slider {
  -webkit-appearance: none;
  appearance: none;
  width: 72px;
  height: 4px;
  background: rgba(255, 255, 255, 0.15);
  border-radius: 2px;
  outline: none;
  cursor: pointer;
  margin: 0 4px 0 0;
}

.dock-audio-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 12px;
  height: 12px;
  background: var(--theme-primary);
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 0 6px var(--theme-glow);
  border: 1.5px solid rgba(255, 255, 255, 0.8);
  transition: transform 0.15s ease;
}

.dock-audio-slider::-webkit-slider-thumb:hover {
  transform: scale(1.2);
}

.dock-audio-slider::-webkit-slider-runnable-track {
  height: 4px;
  background: rgba(255, 255, 255, 0.15);
  border-radius: 2px;
}

.dock-audio-slider::-moz-range-track {
  height: 4px;
  background: rgba(255, 255, 255, 0.15);
  border-radius: 2px;
}

.dock-audio-slider::-moz-range-thumb {
  width: 12px;
  height: 12px;
  background: var(--theme-primary);
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 0 6px var(--theme-glow);
  border: 1.5px solid rgba(255, 255, 255, 0.8);
}

.dock-audio-slider::-moz-range-progress {
  background: var(--theme-primary);
  border-radius: 2px;
  height: 4px;
}
```

**Step 3: Add DockAudioControl JS module**

Insert after UnifiedBar in `JS/bundle.js`:

```javascript
  // --- DockAudioControl: mute icon + inline horizontal slider ---
  var DockAudioControl = (function() {
    var _wrapper, _muteBtn, _icon, _slider;
    var _hideTimer = null;

    function init() {
      _wrapper = document.getElementById('dockAudio');
      _muteBtn = document.getElementById('dockAudioMute');
      _icon = document.getElementById('dockAudioIcon');
      _slider = document.getElementById('dockAudioSlider');

      if (!_wrapper) return;

      // Set initial slider value from state
      if (_slider) {
        _slider.value = Math.round(AudioState.get('volume') * 100);
      }
      _updateIcon();

      // Mute toggle
      if (_muteBtn) {
        _muteBtn.addEventListener('click', function(e) {
          e.stopPropagation();
          e.preventDefault();
          AudioState.set('muted', !AudioState.get('muted'));
        });
      }

      // Slider input
      if (_slider) {
        _slider.addEventListener('input', function(e) {
          var vol = parseInt(e.target.value) / 100;
          AudioState.set('volume', vol);
          if (vol > 0) {
            AudioState.set('muted', false);
          }
        });
      }

      // Keep slider visible briefly after mouse leaves
      if (_wrapper) {
        _wrapper.addEventListener('mouseleave', function() {
          _hideTimer = setTimeout(function() {
            _wrapper.classList.remove('slider-visible');
          }, 300);
        });
        _wrapper.addEventListener('mouseenter', function() {
          if (_hideTimer) {
            clearTimeout(_hideTimer);
            _hideTimer = null;
          }
        });
      }

      // Listen for state changes from any source
      AudioBus.on('volume:change', function(data) {
        if (_slider) _slider.value = Math.round(data.volume * 100);
        _updateIcon();
      });

      AudioBus.on('muted:change', function() {
        _updateIcon();
      });

      AudioBus.on('video:loaded', function() {
        if (_wrapper) _wrapper.style.display = 'flex';
        // Sync slider to current state
        if (_slider) _slider.value = Math.round(AudioState.get('volume') * 100);
        _updateIcon();
      });

      AudioBus.on('video:removed', function() {
        if (_wrapper) _wrapper.style.display = 'none';
      });

      console.log('[DockAudioControl] Initialized');
    }

    function _updateIcon() {
      if (!_icon) return;
      var muted = AudioState.get('muted');
      var volume = AudioState.get('volume');

      if (muted || volume === 0) {
        _icon.innerHTML = '&#128263;'; // muted speaker
      } else if (volume < 0.5) {
        _icon.innerHTML = '&#128265;'; // low volume
      } else {
        _icon.innerHTML = '&#128266;'; // high volume
      }
    }

    return { init: init };
  })();
```

**Step 4: Verify no errors**

Reload panel. Console shows `[DockAudioControl] Initialized`. Dock audio area hidden (no video loaded).

**Step 5: Commit**

```bash
git add index.html style.css JS/bundle.js
git commit -m "feat(audio): add DockAudioControl with inline horizontal slider"
```

---

## Task 7: Wire Audio System Into loadVideo and restoreState

**Files:**
- Modify: `JS/bundle.js:2444-2614` (loadVideo function — replace old audio/timeline wiring)

**Step 1: Replace old video cleanup with AudioState.detachVideo()**

In `loadVideo()`, replace lines 2444-2452:

```javascript
    var oldVideo = document.querySelector('video.gif');
    if (oldVideo) {
      // Clean up old blob URL if it was a blob
      var oldSrc = oldVideo.src;
      oldVideo.pause();
      oldVideo.src = '';
      oldVideo.load();
      oldVideo.remove();
      cleanupBlobUrl(oldSrc);
    }
```

With:

```javascript
    var oldVideo = document.querySelector('video.gif');
    if (oldVideo) {
      AudioState.detachVideo();
      var oldSrc = oldVideo.src;
      oldVideo.pause();
      oldVideo.src = '';
      oldVideo.load();
      oldVideo.remove();
      cleanupBlobUrl(oldSrc);
    }
```

**Step 2: Replace old oncanplay logic with AudioState.attachVideo()**

Replace the entire `video.oncanplay` handler (lines 2482-2518):

```javascript
    video.oncanplay = function() {
      console.log('✅ Video can play');
      hideLoader();

      // Ensure muted for autoplay (browser requirement)
      video.muted = true;

      // Try to play with retry mechanism
      function tryPlay(attempts) {
        var playPromise = video.play();
        if (playPromise !== undefined) {
          playPromise.then(function() {
            console.log('✅ Video playing');
            // Now restore user's mute preference
            var shouldMute = localStorage.getItem('gifPreview.audioMuted') !== 'false';
            video.muted = shouldMute;
            var volume = parseFloat(localStorage.getItem('gifPreview.volume') || '1');
            video.volume = volume;
            var savedPlaybackSpeed = parseFloat(localStorage.getItem(STORAGE_KEYS.PLAYBACK_SPEED) || '1');
            if (savedPlaybackSpeed !== 1) {
              video.playbackRate = savedPlaybackSpeed;
            }
          }).catch(function(error) {
            console.warn('⚠️ Autoplay attempt ' + (4 - attempts) + ' blocked:', error.message);
            // Retry a few times with delay
            if (attempts > 0) {
              setTimeout(function() { tryPlay(attempts - 1); }, 200);
            }
          });
        }
      }

      var autoplayOn = localStorage.getItem(STORAGE_KEYS.AUTOPLAY) !== 'false';
      if (autoplayOn) {
        tryPlay(3);
      }
    };
```

With:

```javascript
    video.oncanplay = function() {
      console.log('✅ Video can play');
      hideLoader();
      AudioState.attachVideo(video);
    };
```

**Step 3: Remove old dock audio/timeline wiring at end of loadVideo()**

Replace lines 2604-2614:

```javascript
    var audioWrapper = document.getElementById('audioControlWrapper');
    if (audioWrapper) {
      audioWrapper.style.display = 'flex';
    }
    if (dom.btnAudio) {
      var savedMuted = localStorage.getItem('gifPreview.audioMuted') !== 'false';
      dom.btnAudio.innerHTML = '<span class="ico">' + (savedMuted ? '🔇' : '🔊') + '</span>';
    }
    // Initialize dock volume slider
    initDockVolumeSlider();
    if (window.showTimeline) window.showTimeline();
```

With:

```javascript
    // Audio system wiring is handled by AudioState.attachVideo() via events
```

**Step 4: Verify video load works**

Drop a video onto the panel. Should see:
- `[AudioState] Video attached` in console
- `[AudioState] Video detached` if you drop a second video
- Unified bar appears with waveform (or flat fallback)
- Dock audio control appears with mute button
- Play/pause works

**Step 5: Commit**

```bash
git add JS/bundle.js
git commit -m "feat(audio): wire AudioState into loadVideo, replace old inline audio logic"
```

---

## Task 8: Remove Old Audio Functions and Simplify Keyboard Handler

**Files:**
- Modify: `JS/bundle.js` (multiple locations)

**Step 1: Delete initTimeline() function**

Delete lines 4564-4668 (the entire `function initTimeline()` block including `window.showTimeline` and `window.hideTimeline`).

**Step 2: Delete initDockVolumeSlider() function**

Delete lines 4500-4562 (the entire `function initDockVolumeSlider()` block).

**Step 3: Delete initVolumeControls() function**

Delete lines 4417-4498 (the entire `function initVolumeControls()` block).

**Step 4: Remove old init calls and add new ones**

In `initControls()`, replace lines 3991-3994:

```javascript
    initDragDrop();
    initSettingsModal();
    initTimeline();
    initVolumeControls();
```

With:

```javascript
    initDragDrop();
    initSettingsModal();
    // Audio system init
    UnifiedBar.init();
    DockAudioControl.init();
```

**Step 5: Simplify keyboard handler**

Replace lines 3996-4090 (the entire `document.addEventListener('keydown', ...)` block) with:

```javascript
    document.addEventListener('keydown', function(e) {
      if (e.key === 'Escape') {
        var settingsModal = document.getElementById('settingsModal');
        var updateModal = document.getElementById('updateModal');

        if (settingsModal && !settingsModal.classList.contains('hidden')) {
          settingsModal.classList.add('hidden');
          return;
        }

        if (updateModal && !updateModal.classList.contains('hidden')) {
          updateModal.classList.add('hidden');
          return;
        }

        if (dom.app.classList.contains('uiHidden')) {
          dom.app.classList.remove('uiHidden');
          localStorage.setItem('gifPreview.uiHidden', '0');
        }
        return;
      }

      // Skip if input is focused or modal is open
      var activeEl = document.activeElement;
      if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) return;
      var settingsM = document.getElementById('settingsModal');
      if (settingsM && !settingsM.classList.contains('hidden')) return;
      var licenseM = document.getElementById('licenseModal');
      if (licenseM && !licenseM.classList.contains('hidden')) return;

      // Space = play/pause (only shortcut)
      if (e.code === 'Space') {
        e.preventDefault();
        var video = AudioState.getVideo();
        if (video) {
          if (video.paused) { video.play(); } else { video.pause(); }
        }
      }
    });
```

**Step 6: Remove old btnAudio onclick handler**

Replace lines 3950-3988 (the `if (dom.btnAudio)` block with the old mute toggle + popup logic) with:

```javascript
    // btnAudio click is now handled by DockAudioControl
```

**Step 7: Verify everything works**

- Drop video: unified bar shows, dock audio shows
- Click play/pause on unified bar: works
- Press Space: play/pause toggles
- Arrow keys: no longer do anything (correct)
- Click mute on dock: toggles mute, icon updates
- Hover dock audio: slider slides out
- Scrub on waveform: seeks correctly

**Step 8: Commit**

```bash
git add JS/bundle.js
git commit -m "refactor(audio): remove old audio functions, simplify keyboard to Space only"
```

---

## Task 9: Rewire Settings Panel Volume Controls

**Files:**
- Modify: `index.html:331-361` (simplify settings volume UI)
- Modify: `index.html:434-444` (simplify shortcut reference)
- Modify: `JS/bundle.js` (add settings volume wiring via AudioBus)

**Step 1: Simplify settings volume HTML**

Replace lines 331-361 (the old volume control container) with:

```html
              <!-- Volume Control -->
              <div class="settingRow value-setting">
                <label>Video Volume</label>
                <div class="volume-control-container">
                  <div class="volume-icon-wrapper">
                    <button id="volumeMuteBtn" class="volume-mute-btn" title="Mute/Unmute">
                      <span class="volume-icon">&#128266;</span>
                    </button>
                  </div>
                  <div class="volume-slider-wrapper">
                    <input type="range" id="volumeControl" min="0" max="1" step="0.01" value="1" class="volume-slider">
                    <div class="volume-track">
                      <div class="volume-progress"></div>
                    </div>
                  </div>
                  <div class="volume-value-wrapper">
                    <span class="volume-percentage">100%</span>
                  </div>
                </div>
              </div>
```

Note: Removed the `.volume-bars` (decorative bars) and `.volume-ticks` — these are no longer needed with the real waveform.

**Step 2: Simplify shortcut reference**

Replace lines 434-444 (the old shortcuts grid) with:

```html
              <!-- Shortcuts Reference -->
              <div class="settingRow shortcuts-ref">
                <label>Shortcut Reference</label>
                <div class="shortcuts-grid">
                  <div class="shortcut-item"><kbd>Space</kbd><span>Play / Pause</span></div>
                </div>
              </div>
```

**Step 3: Add settings volume wiring via AudioBus**

Add a new function in `JS/bundle.js` after DockAudioControl (before `// 4. STORAGE KEYS`):

```javascript
  // --- Settings panel volume wiring (uses AudioBus) ---
  function initSettingsVolumeControls() {
    var volumeSlider = document.getElementById('volumeControl');
    var volumePercentage = document.querySelector('.volume-percentage');
    var volumeMuteBtn = document.getElementById('volumeMuteBtn');
    var volumeProgress = document.querySelector('.volume-progress');

    if (!volumeSlider) return;

    // Set initial values from AudioState
    volumeSlider.value = AudioState.get('volume');
    _updateSettingsVolumeUI(AudioState.get('volume'), AudioState.get('muted'));

    // Slider input
    volumeSlider.addEventListener('input', function(e) {
      var volume = parseFloat(e.target.value);
      AudioState.set('volume', volume);
      if (volume > 0) {
        AudioState.set('muted', false);
      }
    });

    // Mute button
    if (volumeMuteBtn) {
      volumeMuteBtn.addEventListener('click', function() {
        AudioState.set('muted', !AudioState.get('muted'));
      });
    }

    // Listen for changes from any source
    AudioBus.on('volume:change', function(data) {
      if (volumeSlider) volumeSlider.value = data.volume;
      _updateSettingsVolumeUI(data.volume, AudioState.get('muted'));
    });

    AudioBus.on('muted:change', function(data) {
      _updateSettingsVolumeUI(AudioState.get('volume'), data.muted);
    });

    function _updateSettingsVolumeUI(volume, muted) {
      if (volumePercentage) {
        volumePercentage.textContent = Math.round(volume * 100) + '%';
      }
      if (volumeProgress) {
        volumeProgress.style.width = (volume * 100) + '%';
      }
      if (volumeMuteBtn) {
        var icon = volumeMuteBtn.querySelector('.volume-icon');
        if (icon) {
          if (muted || volume === 0) {
            icon.textContent = '\uD83D\uDD07'; // muted
          } else if (volume < 0.5) {
            icon.textContent = '\uD83D\uDD09'; // low
          } else {
            icon.textContent = '\uD83D\uDD0A'; // high
          }
        }
      }
    }

    console.log('[SettingsVolume] Initialized via AudioBus');
  }
```

**Step 4: Call initSettingsVolumeControls from initControls**

In `initControls()`, add after the audio system init lines:

```javascript
    initDragDrop();
    initSettingsModal();
    UnifiedBar.init();
    DockAudioControl.init();
    initSettingsVolumeControls(); // ADD THIS
```

**Step 5: Wire settings toggles through AudioState**

Find where the settings toggles (loop, autoplay, speed) write to localStorage and update them to also call `AudioState.set()`. Search for each toggle handler in `initSettingsModal()` and add the AudioState call. For example, for the loop toggle:

```javascript
// After: localStorage.setItem(STORAGE_KEYS.LOOP_ENABLED, loopToggle.checked.toString());
// Add:
AudioState.set('loop', loopToggle.checked);
```

Same pattern for autoplay toggle and speed selector buttons.

**Step 6: Verify settings ↔ dock sync**

1. Open settings, change volume slider → dock slider updates
2. Hover dock, change dock slider → settings slider updates
3. Click mute in settings → dock icon updates
4. Click mute on dock → settings icon updates

**Step 7: Commit**

```bash
git add index.html JS/bundle.js
git commit -m "feat(audio): rewire settings volume controls through AudioBus"
```

---

## Task 10: Clean Up Old CSS for Volume Bars and Ticks

**Files:**
- Modify: `style.css` (remove orphaned CSS for `.volume-bars`, `.volume-ticks`, `.bar.active`, etc.)

**Step 1: Search and remove orphaned CSS rules**

Search `style.css` for these selectors and delete them:
- `.volume-bars` and all nested rules (`.volume-bars .bar`, `.volume-bars .bar.active`)
- `.volume-ticks` and nested `.tick` rules
- Any remaining `.volume-popup` references that weren't caught in Task 6

**Step 2: Verify no visual regressions**

Open settings panel. Volume control should look clean without decorative bars. No broken layouts.

**Step 3: Commit**

```bash
git add style.css
git commit -m "chore(audio): remove orphaned volume bars and ticks CSS"
```

---

## Task 11: End-to-End Testing

**Files:** No changes — verification only.

**Step 1: Test fresh panel load (no saved state)**

1. Clear localStorage (`localStorage.clear()` in DevTools console)
2. Reload panel
3. Verify: placeholder shown, no errors, audio controls hidden, `[AudioState] Initialized` in console with `muted: false`, `volume: 1`

**Step 2: Test video drop**

1. Drop an MP4 with audio onto panel
2. Verify: video plays, unified bar appears with waveform, dock audio control appears
3. Verify: mute icon shows correct state, time display updates

**Step 3: Test rapid file switching (bug #2 fix)**

1. Drop video A
2. Immediately drop video B (within 1 second)
3. Verify: no stale playback, no `tryPlay` errors, video B plays cleanly
4. Console should show `[AudioState] Video detached` then `[AudioState] Video attached`

**Step 4: Test volume sync (bug #1 fix)**

1. Change volume in settings → dock slider should match
2. Change volume on dock → settings slider should match
3. Mute on dock → settings icon updates
4. Mute in settings → dock icon updates

**Step 5: Test history restore (bug #3 fix)**

1. Load a video
2. Close and reopen panel (or reload)
3. Verify: video restores AND unified bar appears with waveform

**Step 6: Test waveform fallback**

1. Drop a video with NO audio track (screen recording, GIF-converted MP4)
2. Verify: flat progress bar shown instead of waveform, no errors, scrubbing works

**Step 7: Test keyboard**

1. Press Space → play/pause toggles
2. Press arrow keys → nothing happens (correct)
3. Press M → nothing happens (correct)
4. Press Escape → closes modal / restores UI (still works)

**Step 8: Test "Show Timeline" toggle**

1. Open settings → Playback → turn off "Show Timeline"
2. Load a video → unified bar should NOT appear
3. Turn it back on → bar appears

**Step 9: Commit (if any fixes were needed)**

```bash
git add -A
git commit -m "fix(audio): address issues found during end-to-end testing"
```

---

## Summary of All Tasks

| Task | Description | Files |
|---|---|---|
| 1 | AudioBus event emitter | `JS/bundle.js` |
| 2 | AudioState centralized state | `JS/bundle.js` |
| 3 | WaveformRenderer | `JS/bundle.js` |
| 4 | UnifiedBar HTML + CSS | `index.html`, `style.css` |
| 5 | UnifiedBar JavaScript | `JS/bundle.js` |
| 6 | DockAudioControl (HTML + CSS + JS) | `index.html`, `style.css`, `JS/bundle.js` |
| 7 | Wire into loadVideo / restoreState | `JS/bundle.js` |
| 8 | Remove old functions, simplify keyboard | `JS/bundle.js` |
| 9 | Rewire settings volume controls | `index.html`, `JS/bundle.js` |
| 10 | Clean up orphaned CSS | `style.css` |
| 11 | End-to-end testing | *(verification only)* |
