# Client Store & List Windowing (Strategic Refactor)

Priority: **Medium (strategic)** · Effort: **High** · Risk: **Medium**

This is the largest item of the eight. Unlike the others, it is a refactor rather than a bug fix. It should be sequenced *after* the XSS, cache, and WS work so the new structure inherits correct behavior. It can ship incrementally.

## Problem

The client is a pre-module vanilla-JS app that has outgrown its structure:

1. **One mutable global** (`window.MusicRoom` / `M`, `public/core.js:4-73`) holds *all* state — audio element, WS, current track, queue, library, cache maps, permission flags, and UI bookkeeping (`lastProgressPct`, etc.). Every IIFE mutates `M` directly and calls `M.render*()`. There is no contract between modules; forward references are sometimes guarded with `M.foo && M.foo()` (`channelSync.js:502`), often not.

2. **Full re-render of every row on every change.** `trackContainer.js:101-165` clears `innerHTML` and rebuilds every track row — triggered by every WS state update, every 5s cache poll (`ui.js:175-182`), and every prefetch completion. No windowing/virtualization: a 10k-track library renders 10k `.track` divs with fresh closures each time. Search (`queue.js:196-204`) filters the full array synchronously per keystroke.

3. **God module.** `public/trackContainer.js` (972 lines) fuses rendering, drag state, selection, drop-zones, and context-menu DOM.

4. **Duplicated logic**: local-playback setup (4×), cookie get/set/clear (3×), `escapeHtml` (2×), segment-availability scan (3×).

## Affected Locations

- `public/core.js:4-73` — global state
- `public/trackContainer.js` (972 lines) — render + drag + selection + context menu
- `public/trackContainer.js:101-165` — `render()` full clear+rebuild
- `public/ui.js:175-182` — 5s cache-poll re-render trigger
- `public/queue.js:196-204` — synchronous per-keystroke filter
- All IIFEs in `public/*.js` — module pattern

## Goals (in priority order)

1. **Virtualize the library list** so 10k+ tracks render smoothly.
2. **Introduce a tiny reactive store** so state changes are explicit and modules subscribe rather than mutate globals.
3. **Decompose `trackContainer.js`** into focused modules.
4. **De-duplicate** playback setup, cookie helpers, escaping.

Non-goals: adopting a framework (React/Vue/etc.). Bun serves static files with no build step; introducing one is out of scope unless explicitly desired. This plan stays vanilla.

## Implementation Plan

The plan is staged so each phase ships value independently.

### Phase 1 — Virtualize the list (highest standalone value)

Even without a store refactor, windowing fixes the worst performance problem.

**1.1 Add a windowed renderer** for the library (and queue when long). Only render rows visible in the viewport plus a small overscan buffer (e.g. 10 rows above/below). Use a sentinel/spacer div with the full scroll height so the scrollbar stays accurate.

Sketch (`trackContainer.js`):

```js
const ROW_HEIGHT = 44;        // measure actual rendered row height
const OVERSCAN = 10;

function renderWindow(container, items, scrollTop, viewportHeight) {
  const totalHeight = items.length * ROW_HEIGHT;
  const firstVisible = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN * 2;
  const slice = items.slice(firstVisible, firstVisible + visibleCount);

  container.innerHTML = "";   // or keep a stable spacer + content wrapper
  const spacerTop = document.createElement("div");
  spacerTop.style.height = `${firstVisible * ROW_HEIGHT}px`;
  const spacerBottom = document.createElement("div");
  spacerBottom.style.height = `${(items.length - firstVisible - slice.length) * ROW_HEIGHT}px`;
  container.appendChild(spacerTop);
  for (let i = 0; i < slice.length; i++) {
    container.appendChild(renderRow(slice[i], firstVisible + i));
  }
  container.appendChild(spacerBottom);
}
```

**1.2 Drive renders from `scroll` events** (passive listener) and `requestAnimationFrame`-throttled. Debounce on resize. Do *not* re-render on every WS update — instead mark rows dirty and only update the visible slice.

**1.3 Index rows by track id** so targeted updates (e.g. a single track's cache status changes) patch a specific DOM node instead of rebuilding the list.

**1.4 Move search filtering to the model layer**: maintain `M.libraryFiltered` as a computed array; the filter runs once per query (debounced ~150ms), and the windowed renderer reads from it. Never re-filter on every keystroke synchronously.

Deliverable: a library that scrolls smoothly at 10k tracks.

### Phase 2 — Introduce a minimal reactive store

Replace ad-hoc `M.x = …; M.render()` with a store that notifies subscribers.

**2.1 Store shape** (new file `public/store.js`):

```js
(function () {
  const M = window.MusicRoom;
  const state = {};        // the actual values
  const subs = new Map();  // key -> Set<callback>

  M.store = {
    get(key) { return state[key]; },
    set(key, value) {
      if (Object.is(state[key], value)) return;   // skip no-op
      state[key] = value;
      subs.get(key)?.forEach((cb) => cb(value));
    },
    update(key, fn) { M.store.set(key, fn(state[key])); },
    on(key, cb) {
      if (!subs.has(key)) subs.set(key, new Set());
      subs.get(key).add(cb);
      return () => subs.get(key)?.delete(cb);     // unsubscribe
    },
  };
})();
```

**2.2 Migrate state field by field.** Don't do a big-bang rewrite. Start with the fields that change most and drive renders: `M.library`, `M.queue`, `M.cachedTracks`, `M.currentTrackId`. Each becomes `M.store.set("library", …)` and the renderer subscribes via `M.store.on("library", renderLibrary)`.

Keep the `M.library` getter as a compatibility shim (`Object.defineProperty(M, "library", { get: () => M.store.get("library") })`) so unmigrated callers keep working during the transition.

**2.3 Decouple renders from setters.** Today `audioCache.js` calls `M.renderQueue()` directly. After migration it just `M.store.set("cachedTracks", …)` and the queue renderer — which subscribed once — decides whether and what to re-render.

Deliverable: a single source of truth per field, explicit update points, and the ability to add per-field logging/invariants cheaply.

### Phase 3 — Decompose `trackContainer.js`

Split the 972-line file along its existing seams:

- `trackList.js` — the windowed renderer (from Phase 1) + list-level keyboard nav.
- `trackSelection.js` — the `selection`/`lastSelected` state (`trackContainer.js:11-22`) and click/shift-click range logic.
- `trackDrag.js` — drag state (`trackContainer.js:8,24-31`), drop-zone rendering, queue reorder calls.
- `trackContextMenu.js` — menu construction + DOM (`showContextMenuUI` at `:880-949`), built on top of the row events.

Each becomes a small module that subscribes to the store (Phase 2) and exposes a narrow API on `M.tracks.*`.

Deliverable: no file over ~400 lines in the client.

### Phase 4 — De-duplicate

- **Playback setup**: extract a single `M.loadAndPlay(track, { seek: 0 })` used by `init.js`, `controls.js`, and both call sites in `trackContainer.js`. Today it's copy-pasted 4× (see assessment).
- **Cookie helpers**: one `M.prefs.get/set/clear` (with `Secure` added — see XSS plan) replacing the three copies in `core.js`, `themes.js`, `visualizer.js`.
- **`escapeHtml`**: single `M.escapeHtml` (already part of the XSS plan).
- **Segment scan**: one `M.getBufferedSegments(audio, trackId)` used by `audioCache.js`, `ui.js`, `controls.js`.

Deliverable: fewer copies, clearer ownership.

## Validation

- **List performance**: load a fixture library of 10k tracks (synthesize or copy metadata rows). Measure initial render time, scroll FPS, and keystroke-to-render latency in DevTools Performance. Target: 60fps scroll, <100ms keystroke response.
- **Correctness parity**: after each phase, verify against a manual checklist — play, pause, seek, jump, add-to-queue, remove-from-queue, drag-reorder, multi-select, context-menu actions, cache indicator updates, search filter, channel switch. Each phase must preserve all of these.
- **Store**: add a temporary `M.store.on("library", (v) => console.count("library"))` and confirm the count matches expected update frequency (not 5× per WS message).
- **No regressions in cache behavior**: the windowed render must still update a row's cache indicator when its blob completes — verify by playing a track and watching its row in a scrolled-out list update without a full re-render.
- **Decomposition**: confirm no module exceeds ~400 lines and that each can be reasoned about in isolation.

## Risk / Rollback

- This is the highest-risk item because it touches the most code. Mitigate by:
  - Shipping phases in order; each is independently mergeable and each is a checkpoint.
  - Keeping `M.library`-style getters as shims during Phase 2 so partial migrations don't break.
  - Behind a fallback: if windowing introduces scroll glitches, the non-windowed path can be kept as `M.renderLegacyList` and re-enabled until fixed.
- No server or DB changes; rollback is purely client-side file reversion.
- Do **not** attempt this before the XSS, cache-race, and WS plans land — those fix correctness bugs that a refactor would otherwise carry forward or obscure.
