155 lines
9.2 KiB
Markdown
155 lines
9.2 KiB
Markdown
# 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.
|