# Audio UI Rewrite — Full Design

**Date:** 2026-02-24
**Scope:** Complete rewrite of audio UI and underlying state management
**Approach:** Self-contained audio module (Approach B) within bundle.js

## Goals

- Minimal/clean visual design (Spotify mini-player aesthetic)
- Static waveform as the scrub track
- Event-driven pub/sub state management
- Merged timeline + waveform into one unified bar
- Inline horizontal volume slider on dock (no popup)
- Single keyboard shortcut (Space for play/pause)
- Fix all 7 known stability bugs

---

## Architecture

Five components, all communicating through a shared event bus:

```
AudioBus (pub/sub)
  ├── AudioState (centralized state + localStorage sync)
  ├── WaveformRenderer (OfflineAudioContext → canvas)
  ├── UnifiedBar (waveform + scrub + play/pause + time)
  └── DockAudioControl (mute icon + inline slider)
```

---

## 1. AudioBus — Event System

Lightweight pub/sub emitter. All audio components communicate through this.

**API:**
- `.on(event, callback)` — subscribe
- `.off(event, callback)` — unsubscribe
- `.emit(event, data)` — publish

**Events:**

| Event | Emitted by | Data | Consumed by |
|---|---|---|---|
| `volume:change` | Any volume control | `{ volume: 0.0-1.0 }` | All volume UIs, video element |
| `mute:change` | Any mute toggle | `{ muted: bool }` | All mute UIs, video element |
| `playback:play` | Play button / Space | `{}` | Play icons, unified bar |
| `playback:pause` | Pause button / Space | `{}` | Play icons, unified bar |
| `playback:timeupdate` | Video timeupdate | `{ current, duration }` | Waveform progress, time display |
| `playback:seek` | Scrub interaction | `{ time: seconds }` | Video element |
| `playback:ended` | Video ended event | `{}` | Play icons |
| `video:loaded` | loadVideo() | `{ video: element }` | All components needing video ref |
| `video:removed` | Before new load | `{}` | Cleanup listeners, hide bar |

---

## 2. AudioState — Centralized State

Single source of truth for all audio/playback state. Replaces scattered localStorage calls and the unused `state` object at bundle.js:1150.

**API:**
- `.get(key)` — read current value
- `.set(key, value)` — update + emit + persist (idempotent, no-op if unchanged)
- `.init()` — load from localStorage on startup
- `.attachVideo(videoEl)` — bind state to a video element
- `.detachVideo()` — clean up before video swap

**State shape:**

| Key | Type | Default | localStorage key |
|---|---|---|---|
| `volume` | `0.0-1.0` | `1` | `gifPreview.volume` |
| `muted` | `bool` | `false` | `gifPreview.audioMuted` |
| `playing` | `bool` | `false` | *(not persisted)* |
| `currentTime` | `number` | `0` | *(not persisted)* |
| `duration` | `number` | `0` | *(not persisted)* |
| `speed` | `number` | `1` | `gifPreview.playbackSpeed` |
| `loop` | `bool` | `true` | `gifPreview.loopEnabled` |
| `autoplay` | `bool` | `true` | `gifPreview.autoplay` |

**Key changes from current code:**
- Muted defaults to `false` (not `true`). Autoplay compliance handled in `attachVideo()`.
- `attachVideo()` / `detachVideo()` is the single gateway for video-audio binding.
- All `set()` calls are idempotent — same value = no emit, no write.

---

## 3. WaveformRenderer — Static Waveform Generation

Generates a waveform shape from the video's audio track and draws it onto a canvas.

**API:**
- `.generate(videoSrc)` — async, decodes audio, stores amplitude data
- `.draw(canvas, progress)` — renders waveform with progress fill at 0.0-1.0
- `.clear()` — resets canvas and cached data
- `.hasWaveform()` — bool, false if decode failed (using fallback)

**Process:**
1. `fetch()` video src as ArrayBuffer
2. `OfflineAudioContext.decodeAudioData()` to get PCM
3. Downsample to ~200 amplitude bars (peak per chunk)
4. Draw to canvas: base layer (`rgba(255,255,255,0.12)`) + progress overlay (theme primary)

**Fallback:** If decode fails (no audio track, unsupported codec), falls back to a flat line. Scrub bar still works, just no waveform shape.

**Performance:** Decode once per video load (async). Canvas redraw on timeupdate is a single clip operation. On panel resize, redraws from cached amplitude data (no re-decode).

---

## 4. UnifiedBar — Merged Timeline + Waveform + Controls

Replaces the old `#timelineBar`. One bar combining play/pause, waveform scrubbing, and time display.

**Layout:**
```
[ play ] [ ▁▂▃▅▇▅▃▂▁▂▃▅▇█▇▅▃▂▁ ] [ 1:23 / 3:45 ]
```

**Components:**

| Part | Element | Behavior |
|---|---|---|
| Play/pause | `<button>` 24px circle | Toggles via AudioBus, icon syncs from events |
| Waveform track | `<canvas>` flex:1 | WaveformRenderer output, click-to-seek + drag-to-scrub |
| Time display | `<span>` | `m:ss / m:ss`, tabular-nums, updates on `playback:timeupdate` |

**Interactions:**
- Click on waveform — seeks to position
- Drag on waveform — scrub with position tracking
- Hover on waveform — subtle time tooltip via CSS `::after` at mouse X

**Visibility:**
- Hidden by default
- Shown on `video:loaded`, hidden on `video:removed`
- Hidden in fullscreen mode (`.app.uiHidden`)
- Respects "Show Timeline" toggle in settings

**Styling:**
- `rgba(0,0,0,0.4)` + `backdrop-filter: blur(12px)`, `border-radius: 10px`
- `margin: 0 8px`, `padding: 6px 10px`
- Canvas height: 28px
- Waveform base: `rgba(255,255,255,0.12)`, progress: theme primary gradient

**Replaces:** `#timelineBar`, `#timelinePlayBtn`, `#timelineSeek`, `#timelineProgress`, `#timelineThumb`, `#timelineTime`, `initTimeline()`, `window.showTimeline()`, `window.hideTimeline()`

---

## 5. DockAudioControl — Mute + Inline Slider

Replaces the dock audio button and its vertical popup. Mute icon with horizontal mini-slider that slides out on hover.

**Layout:**
```
Resting:   [ mute-icon ]
Hovered:   [ mute-icon ━━━━━●━━━ ]
```

**Behavior:**
- Click mute icon — toggles `mute:change` via AudioBus
- Three icon states: muted, <50%, >=50%
- Inline slider (80px horizontal range) slides out via `max-width` transition (0 → 80px)
- 300ms delay on mouse leave before hiding slider
- No popup, no vertical rotation, no absolute positioning

**Visibility:** Hidden when no video loaded, shown on `video:loaded`, hidden on `video:removed`.

**Replaces:** `.volume-popup`, `#volumeSliderDock`, `.volume-popup-value`, all popup CSS (lines 638-805 in style.css), `initDockVolumeSlider()`, 3-second auto-hide timer.

---

## 6. Integration Points

**Initialization (once, on panel load):**
```
initApp()
  └── initAudioSystem()
        ├── AudioBus.init()
        ├── AudioState.init()
        ├── UnifiedBar.init()
        ├── DockAudioControl.init()
        └── register Space keydown
```

Replaces: `initTimeline()`, `initDockVolumeSlider()`, `initVolumeControls()`, keyboard block (lines 3996-4090 trimmed to Escape + Space).

**Video load:**
```
loadVideo()
  └── BEFORE new <video>: AudioState.detachVideo()
  └── AFTER oncanplay: AudioState.attachVideo(video)
                        WaveformRenderer.generate(videoSrc)
```

Replaces: inline mute/volume/speed restore (lines 2486-2517), `window.showTimeline()` call.

**restoreState():** Goes through same `attachVideo()` → `video:loaded` path. UnifiedBar subscribes to event regardless of source.

**Autoplay + mute compliance in attachVideo():**
1. `video.muted = true` (browser requirement)
2. `video.play()`
3. On resolve: apply AudioState values (muted, volume, speed)
4. On reject + retry: check `video !== _video` before acting (stale = abort)

**Settings panel:** Existing `#volumeControl` slider simplified — emits `volume:change` on input, listens to `volume:change` for updates.

**Keyboard:** Only Escape (modal close, unchanged) and Space (play/pause via AudioBus).

---

## 7. Stability Fixes

| # | Bug | Fix | Component |
|---|---|---|---|
| 1 | Volume slider sync gap | Both sliders use AudioBus events, no direct coupling | AudioBus + DockAudioControl |
| 2 | Autoplay retry race condition | `detachVideo()` nulls `_video`, retry checks identity | AudioState |
| 3 | Timeline not attaching on restore | UnifiedBar subscribes to `video:loaded` event | UnifiedBar + AudioBus |
| 4 | Unused state object | Removed, replaced by AudioState | AudioState |
| 5 | Mute default confusion | Explicit defaults in `init()`, `muted = false` | AudioState |
| 6 | Video element recreation fragility | Centralized `attachVideo()`/`detachVideo()` lifecycle | AudioState |
| 7 | No real audio visualization | WaveformRenderer with OfflineAudioContext | WaveformRenderer |

---

## Code to Remove

**HTML (index.html):**
- Lines 742-754: old `#timelineBar`
- Lines 784-794: old `.audio-control-wrapper` with `.volume-popup`
- Lines 331-361: old volume control container in settings (replace with simplified version)
- Lines 434-444: shortcut reference grid (only Space remains)

**CSS (style.css):**
- Lines 638-805: volume popup styles
- Lines 1916-2023: old timeline styles

**JS (bundle.js):**
- `state` object at line 1150 (replaced by AudioState)
- `initTimeline()` function (lines 4564-4668)
- `initDockVolumeSlider()` function
- `initVolumeControls()` function
- Keyboard shortcut block (lines 4026-4090, trim to Space only)
- Inline mute/volume/speed restore in `loadVideo()` (lines 2486-2517)
- `window.showTimeline` / `window.hideTimeline` globals

## Code to Add

All new code goes into a clearly marked section in bundle.js:

```
// ============================================================================
// AUDIO SYSTEM (v4.0)
// ============================================================================
// AudioBus, AudioState, WaveformRenderer, UnifiedBar, DockAudioControl
```

New HTML elements for UnifiedBar and DockAudioControl replace the removed elements at the same DOM positions.

New CSS section replaces the removed audio/timeline styles.
