Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ Each panel is always in exactly one of these modes. Flags are mutually exclusive

When a panel is in viz mode, splitscreen shows a `vizSettingsBtn` ("Viz ⚙") that opens `vizPopover` — a small popover with controls that override that viz plugin's settings **for this panel only**. The controls are generated from a descriptor, so adding a new per-panel option doesn't require touching the popover code.

- **Descriptor lookup** — `getPanelControlsFor(pluginId)` first reads `settings` from the matching provider in `window.feedBack.vizDomain.snapshot().providers`. Any visualization can declare these in `capabilities.visualization.settings` and implement `applySetting(key, value)` on each renderer instance. Descriptors use `{ key, label, type:'toggle'|'range'|'select', default, min?, max?, step?, options? }`, with `options` as `[{id,label}]`. The old factory `panelControls` and built-in highway_3d descriptor remain as compatibility fallbacks. An empty declared array hides the button.
- **Descriptor lookup** — `getPanelControlsFor(pluginId)` first reads `settings` from the matching provider in `window.feedBack.vizDomain.snapshot().providers`. Any visualization can declare these in `capabilities.visualization.settings` and implement `applySetting(key, value)` on each renderer instance. Descriptors use `{ key, label, type:'toggle'|'range'|'select', default, min?, max?, step?, options? }`, with `options` as `[{id,label}]`. A descriptor may also set `dependsOn: '<toggle key>'`; the popover then greys out and disables that control (with a "Requires …" tooltip) until the named toggle in the same list is on. This is honoured only on the legacy factory `panelControls` path. Core whitelists declared `capabilities.visualization.settings` descriptors (`key`, `type`, `label`, `default`, `min`/`max`/`step`, `options`) in both `static/capabilities.js` and `plugins/__init__.py`, so `dependsOn` is dropped there silently, with no warning. `getPanelControlsFor` prefers declared settings and only falls back to `panelControls` for `highway_3d`, so the day a plugin starts declaring its settings its `dependsOn` stops being read and the greying disappears without any signal. Core must carry the field before a declaring plugin can rely on it. Self-references and dependency cycles are treated as ungated, and a control behind a greyed-out gate is greyed out too. A missing or non-toggle target is ignored and the control stays usable. The old factory `panelControls` and built-in highway_3d descriptor remain as compatibility fallbacks. An empty declared array hides the button.
- **Provider-refresh reconciliation** — core loads plugin scripts *before* `loadLibraryProviders()` publishes providers, so a panel can initialize against an empty snapshot: descriptor lookup finds nothing, so the value is never restored and the button never appears. `_reconcileVizProviders` runs the init-time sequence (restore saved values, then reveal + build the popover) for every live viz panel whose plugin has since declared controls, driven by `window.feedBack.capabilities.subscribe('visualization:providers-refreshed', …)` (the unsub is dropped — the subscription lives as long as the IIFE). Core normalises an empty declared array to absent, so a provider can't use `settings: []` to suppress the legacy fallback.
- **Storage** — capability settings are scoped to renderer instances. Splitscreen persists them under `splitscreenVizSetting:<pluginId>:<vizId>:<key>` and reapplies them when it creates a replacement renderer. The renderer's optional `getSetting(key)` supplies the value the popover *shows* when no override is saved, but `_restoreVizSettings` only re-applies values this panel actually saved (`_vizSavedSetting`). Applying `getSetting()`'s value back would pin an override equal to the plugin's current global, and the panel would stop following later global Settings changes. Legacy highway_3d controls continue using `h3d_bg_panel<N>_<key>` and its global setter event to preserve existing preferences.
- **Panel identity (`vizId`)** — the middle segment is a random id minted lazily on the panel's first declared-control read/write (`_vizId`), **not** the panel's array position: pop-out compacts the survivors and a popup builds its own slot 0, while localStorage is shared between the windows, so an index-keyed value follows the slot and can be read or overwritten by an unrelated panel. A per-window counter doesn't help either (main's first panel and a popup's first panel would both draw from their own `1`). The id is carried through panel prefs (`panelToPrefs` → `initPanel`) and follower config (`_captureFollowerConfig` → pop-out URL / `docked` payload / relay `config`), so it survives layout rebuilds, pop-out/dock and a follower's own re-split; follower sub-panels created beyond the incoming cfgs get their own. Numeric-slot keys from the pre-release scheme are deliberately **not** migrated — that mapping is exactly the ambiguity being removed.
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "splitscreen",
"name": "Split Screen",
"version": "1.15.0",
"version": "1.16.0",
"private": false,
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"capabilities": {
Expand Down
67 changes: 67 additions & 0 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -2081,6 +2081,9 @@
title.textContent = (vizPlugins.find(p => p.id === pluginId)?.name || pluginId) + ' — this panel';
title.style.cssText = 'font-size:10px;color:#6b7280;margin-bottom:6px;white-space:nowrap;';
pop.appendChild(title);
// key -> { row, input } so a control that declares `dependsOn` can be
// greyed out while the toggle it depends on is off.
const built = Object.create(null);
for (const ctl of controls) {
const row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;gap:8px;margin:4px 0;font-size:11px;color:#cbd5e1;white-space:nowrap;cursor:pointer;';
Expand All @@ -2095,6 +2098,7 @@
cb.onchange = () => _vizPanelSet(pluginId, panels.indexOf(panel), ctl, cb.checked);
row.appendChild(name);
row.appendChild(cb);
built[ctl.key] = { row, input: cb };
} else if (ctl.type === 'range') {
const { lo, hi, st } = _ctlRange(ctl);
const sl = document.createElement('input');
Expand All @@ -2113,6 +2117,7 @@
row.appendChild(name);
row.appendChild(sl);
row.appendChild(val);
built[ctl.key] = { row, input: sl };
} else if (ctl.type === 'select') {
const sel = document.createElement('select');
sel.style.cssText = 'background:#1a1a2e;border:1px solid #333;border-radius:4px;padding:2px 4px;font-size:10px;color:#ccc;outline:none;';
Expand All @@ -2125,11 +2130,73 @@
sel.onchange = () => _vizPanelSet(pluginId, panels.indexOf(panel), ctl, sel.value);
row.appendChild(name);
row.appendChild(sel);
built[ctl.key] = { row, input: sel };
} else {
continue;
}
pop.appendChild(row);
}
_wireVizControlDeps(controls, built);
}

/**
* Grey out a control whose descriptor sets `dependsOn: '<toggle key>'`
* until that toggle is on — e.g. 3D Highway's "Locked zoom" does nothing
* unless "Lock camera at frets 1-12" is enabled. Only toggles can gate;
* an unknown or non-toggle `dependsOn` is ignored, so the control stays
* usable.
*
* A bad descriptor must never brick a control, so a control that names
* itself, or sits on a dependency cycle (A -> B -> A), is treated as
* ungated. Gating is transitive: a control behind a gate that is itself
* greyed out is greyed out too, so there is never an editable control
* behind an unreachable gate. Gates are read live, so this follows both
* the saved value at build time and later clicks.
* @param {*} controls the descriptor list the popover was built from
* @param {*} built map of control key -> { row, input } for the built rows
*/
function _wireVizControlDeps(controls, built) {
const byKey = Object.create(null);
for (const c of controls) byKey[c.key] = c;
// The control `ctl` is gated by, or null when it has no valid gate.
const gateOf = (ctl) => {
const dep = typeof ctl.dependsOn === 'string' ? ctl.dependsOn : null;
const depCtl = dep && dep !== ctl.key ? byKey[dep] : null;
return depCtl && depCtl.type === 'toggle' && built[dep] && built[ctl.key] ? depCtl : null;
};
// Walking the gate chain from `key` comes back to `key`.
const inCycle = (key) => {
const seen = new Set([key]);
for (let g = gateOf(byKey[key]); g; g = gateOf(g)) {
if (g.key === key) return true;
if (seen.has(g.key)) return false; // a cycle further on, not through `key`
seen.add(g.key);
}
return false;
};
const gated = controls.filter(c => gateOf(c) && !inCycle(c.key));
if (!gated.length) return;
// Whether `ctl` is usable: no live gate, or its gate is on AND usable.
// Chains end because every cycle member is excluded from `gated`.
const usable = (ctl) => {
if (!gated.includes(ctl)) return true;
const gate = gateOf(ctl);
return !!built[gate.key].input.checked && usable(gate);
};
const refresh = () => {
for (const ctl of gated) {
const on = usable(ctl);
const me = built[ctl.key];
me.input.disabled = !on;
me.row.style.opacity = on ? '' : '0.4';
me.row.style.cursor = on ? 'pointer' : 'not-allowed';
me.row.title = on ? '' : 'Requires "' + gateOf(ctl).label + '"';
}
};
for (const gateKey of new Set(gated.map(c => gateOf(c).key))) {
built[gateKey].input.addEventListener('change', refresh);
}
refresh();
}

/**
Expand Down Expand Up @@ -2458,336 +2525,336 @@
* @param {*} arrIndex
* @param {*} prefs
*/
function initPanel(panel, arrIndex, prefs) {
const isLyricsMode = prefs?.arrName === LYRICS_VALUE;
const isVizMode = prefs?.arrName?.startsWith(VIZ_PREFIX + ':') || false;
let savedVizPluginId = null;
if (isVizMode) {
const parts = prefs.arrName.split(':');
savedVizPluginId = parts[1];
const vizArrName = parts.slice(2).join(':');
const vizIdx = resolveArrIndex(vizArrName);
panel.arrIndex = vizIdx >= 0 ? vizIdx : arrIndex;
} else {
panel.arrIndex = isLyricsMode ? 0 : arrIndex;
}
panel.lyricsMode = false;
panel.lyricsPane = null;
panel.lyricsOverlay = null;
panel.lyricsOverlayOn = false;
panel.chordsOverlay = null;
panel.chordsOverlayOn = false;
panel.vizMode = null;
panel.vizRenderer = null;
// Adopt the saved storage identity for this panel's declared-control
// overrides (main rebuild, pop-out redock and follower re-split all
// route through here). Absent in prefs = a genuinely new panel, which
// mints its own id on first use.
if (prefs?.vizId) panel.vizId = prefs.vizId;

// For viz restore: install the renderer BEFORE hw.init so the canvas
// context is locked to the correct type (2D vs WebGL) on first init.
// See CLAUDE.md "Canvas context-type lock" caveat.
const vizFactoryFn = isVizMode && savedVizPluginId
? vizFactory(savedVizPluginId)
: null;
// Guard the factory call. A buggy viz plugin throwing here would
// bubble out of initPanel and abort the entire splitscreen start
// (caught only by startSplitScreen's catch — every panel torn down
// because one viz factory threw). Fall back to default 2D for just
// this panel instead.
let vizInstalled = false;
if (typeof vizFactoryFn === 'function') {
try {
panel.vizRenderer = vizFactoryFn();
panel.hw.setRenderer(panel.vizRenderer);
vizInstalled = true;
} catch (e) {
console.error('[splitscreen] viz factory threw for', savedVizPluginId, '— falling back to 2D for panel:', e);
}
}

panel.hw.init(panel.canvas);

// Apply saved preferences. Lyrics are handled below by
// _toggleLyricsOverlay, which is the sole source of truth for
// splitscreen's lyrics display — never set the highway's built-in
// lyrics flag from prefs here.
if (prefs && !isLyricsMode) {
if (prefs.inverted !== undefined) panel.hw.setInverted(prefs.inverted);
if (prefs.lefty !== undefined) panel.hw.setLefty(prefs.lefty);
}

const savedMastery = (prefs?.mastery !== undefined) ? prefs.mastery : 1;
panel.hw.setMastery(savedMastery);
panel.masterySlider.value = Math.round(savedMastery * 100);
panel.masterySlider.oninput = () => {
const pct = parseInt(panel.masterySlider.value);
panel.hw.setMastery(pct / 100);
panel.masteryLabel.textContent = pct + '%';
// Debounced: `input` fires on every step while dragging, and a
// full panelPrefs JSON.stringify + localStorage.setItem on every
// one of those is wasted work — coalesce into one write shortly
// after the drag settles.
savePanelPrefsDebounced();
};

// Per-panel viz controls live in the "Viz ⚙" popover, which owns its own
// input handlers (built by buildVizPopover via _showVizControls when the
// panel enters viz mode). Nothing to wire here.

// Pop Out / Dock button handler. In the main window: pop out this panel
// into a new browser window. In the popup (FOLLOWER): post a `docked`
// message so the main reinstates the panel, then close the popup.
panel.popOutBtn.onclick = () => {
if (FOLLOWER) dockFollowerPanel(panel);
else popOutPanel(panel);
};

// Share-to-LAN button handler (main window only). If a share is
// already running, re-target it to this panel's current config so
// fresh viewers boot with what the user just pointed at.
if (panel.lanBtn) {
panel.lanBtn.onclick = () => {
if (!_lanShare) {
if (!startLanShare(panel)) return;
} else {
_lanShare.cfg = _lanCaptureCfg(panel);
try { localStorage.setItem('splitscreenLanShareCfg', JSON.stringify(_lanShare.cfg)); } catch (_) {}
}
_showLanShareModal();
};
}

// Populate arrangement dropdown (includes Lyrics, JT, and viz plugin options).
// Use panel.arrIndex (already resolved from prefs above) so the dropdown
// reflects the saved arrangement even when a special-mode restore is
// about to fall back to plain 2D — e.g. saved viz pref but the renderer
// factory isn't loaded, in which case enterVizMode never runs to correct
// the selection.
populateSelect(panel, panel.arrIndex);

panel.arrName.textContent = isLyricsMode ? 'Lyrics'
: (isVizMode && vizInstalled) ? (arrangements[panel.arrIndex]?.name || '') + ' (viz)'
: (arrangements[panel.arrIndex]?.name || '');

panel.select.onchange = () => {
const val = panel.select.value;
if (val.startsWith(VIZ_PREFIX + ':')) {
const parts = val.split(':');
const pluginId = parts[1];
const vizIdx = parseInt(parts[2]);
panel.arrIndex = vizIdx;
if (panel.vizMode) {
// Build the new renderer first so a throwing factory
// doesn't leave the panel half-torn-down. On throw,
// fall through to a default 2D highway for vizIdx so
// the panel still has a working chart.
let newRenderer;
try {
newRenderer = vizFactory(pluginId)();
} catch (e) {
console.error('[splitscreen] viz factory threw for', pluginId, '— falling back to 2D:', e);
exitVizMode(panel, vizIdx);
return;
}
// Clear the current renderer so it can release its
// resources (WebGL context, event listeners), then
// recreate the highway with the new renderer pre-installed
// — the fresh canvas locks to the new context type, and
// the orphaned old WS can't leak notes into the new chart.
panel.hw.setRenderer(null);
panel.vizRenderer = null;
recreatePanelHighway(panel, { preInstallRenderer: newRenderer });
hookPanelReady(panel);
panel.hw.connect(getWsUrl(currentFilename, vizIdx), { onSongInfo: () => {} });
panel.vizMode = pluginId;
_restoreVizSettings(panel);
const vp = vizPlugins.find(p => p.id === pluginId);
panel.arrName.textContent = (arrangements[vizIdx]?.name || '') + ' (' + (vp?.name || pluginId) + ')';
// Re-bind invert handler on the fresh hw
panel.updateInvertStyle(panel.hw.getInverted());
panel.invertBtn.onclick = () => {
const on = !panel.hw.getInverted();
panel.hw.setInverted(on);
panel.updateInvertStyle(on);
savePanelPrefs();
};
panel.updateLeftyStyle(panel.hw.getLefty());
panel.leftyBtn.onclick = () => {
const on = !panel.hw.getLefty();
panel.hw.setLefty(on);
panel.updateLeftyStyle(on);
savePanelPrefs();
};
_showVizControls(panel, pluginId);
savePanelPrefs();
} else {
enterVizMode(panel, pluginId);
}
} else if (val === LYRICS_VALUE) {
enterLyricsMode(panel);
} else {
const newIdx = parseInt(val);
if (panel.vizMode) {
exitVizMode(panel, newIdx);
} else if (panel.lyricsMode) {
exitLyricsMode(panel, newIdx);
} else {
switchPanelArrangement(panel, newIdx);
}
}
savePanelPrefs();
};

// Per-panel invert toggle
panel.updateInvertStyle(panel.hw.getInverted());
panel.invertBtn.onclick = () => {
const on = !panel.hw.getInverted();
panel.hw.setInverted(on);
panel.updateInvertStyle(on);
savePanelPrefs();
};
panel.updateLeftyStyle(panel.hw.getLefty());
panel.leftyBtn.onclick = () => {
const on = !panel.hw.getLefty();
panel.hw.setLefty(on);
panel.updateLeftyStyle(on);
savePanelPrefs();
};

// Per-panel lyrics toggle. Always renders a transparent overlay band
// anchored to top of the panel (z-index above bar + viz renderers),
// so it works regardless of which renderer (2D, piano, drums, 3D
// Highway, future viz) owns the canvas. Future-proof: any new viz
// plugin gets lyric support for free without modification.
//
// The highway's built-in setLyricsVisible is ALWAYS kept false here,
// never synced to `on`. It defaults to true on a fresh highway, and
// every renderer that respects it draws its own full lyric text —
// the default 2D renderer's drawLyrics(), and viz plugins that read
// bundle.lyricsVisible (e.g. 3D Highway). Passing `on` through used
// to turn overlay-on into a double lyrics render (DOM overlay text +
// the renderer's own copy) on top of whichever renderer is active.
// Forcing it false unconditionally still suppresses that default-true
// for panels with the overlay off, without ever re-enabling it.
panel.lyricsOverlayOn = prefs?.lyrics === true;
const _toggleLyricsOverlay = (on) => {
if (on) {
if (panel.lyricsOverlay) panel.lyricsOverlay.destroy();
panel.lyricsOverlay = createLyricsPane(panel.panelDiv, { overlay: true });
// Connect with arrangement 0 — lyrics are song-level (same
// across arrangements) and this matches enterLyricsMode's
// full-pane connect, so the overlay doesn't need to
// reconnect when the user switches arrangement on the panel.
panel.lyricsOverlay.connect(currentFilename, 0);
} else if (panel.lyricsOverlay) {
panel.lyricsOverlay.destroy();
panel.lyricsOverlay.el.remove();
panel.lyricsOverlay = null;
}
if (typeof panel.hw.setLyricsVisible === 'function') {
panel.hw.setLyricsVisible(false);
}
panel.updateLyricsStyle(on);
};
// Always invoke _toggleLyricsOverlay (even off) so the highway's
// built-in lyricsVisible flag is forced false on init — it defaults
// to true on a fresh highway, and the overlay is the only lyrics
// display splitscreen panels should ever show.
_toggleLyricsOverlay(panel.lyricsOverlayOn);
panel.lyricsBtn.onclick = () => {
panel.lyricsOverlayOn = !panel.lyricsOverlayOn;
_toggleLyricsOverlay(panel.lyricsOverlayOn);
savePanelPrefs();
};

// Per-panel chord diagrams (fretboard overlay), mirroring the lyrics
// overlay above. window.createFretboardOverlay is the fretboard
// plugin's multi-instance factory export (feedBack-plugin-fretboard's
// own single global toggle is untouched by this — each panel here
// gets its own independent instance bound to that panel's own `hw`,
// so it reflects that panel's chart even though the main highway is
// hidden while splitscreen is active). Hidden entirely when the
// fretboard plugin isn't loaded.
const hasFretboardFactory = typeof window.createFretboardOverlay === 'function';
panel.chordsBtn.style.display = hasFretboardFactory ? '' : 'none';
panel.chordsOverlayOn = hasFretboardFactory && prefs?.chords === true;
const _toggleChordsOverlay = (on) => {
if (panel.chordsOverlay) {
panel.chordsOverlay.destroy();
panel.chordsOverlay = null;
}
if (on && hasFretboardFactory) {
panel.chordsOverlay = window.createFretboardOverlay({
container: panel.panelDiv,
getHighway: () => panel.hw,
bottomOffset: () => panel.bar.offsetHeight,
});
}
panel.updateChordsStyle(on);
};
if (hasFretboardFactory) {
_toggleChordsOverlay(panel.chordsOverlayOn);
panel.chordsBtn.onclick = () => {
panel.chordsOverlayOn = !panel.chordsOverlayOn;
_toggleChordsOverlay(panel.chordsOverlayOn);
savePanelPrefs();
};
}

// Per-panel note detection (uses note_detect factory)
panel.detectChannel = prefs?.detectChannel || 'mono';
// Clamp a saved channel that's no longer offered (e.g. a deferred-multichannel
// 'ch3'+ from an earlier build) back to mono so the button label + value resolve.
if (!(panel.detectChannel in DETECT_CHANNEL_VALUE)) panel.detectChannel = 'mono';
panel.detector = null;
// Phase 2: which input DEVICE this panel detects from. 0 = the engine's
// primary input; >0 = an additional interface bound via the device picker.
panel.detectDeviceKey = 0;
// detectDeviceName tracks the device a bind actually SUCCEEDED on — start empty
// (Main). Do NOT preload it from prefs: that would make the panel claim the
// device before its restore-bind ran, so the in-use / duplicate / failure-rollback
// checks treat it as bound and could leak its reserved key. The saved name is the
// restore TARGET only (setupDevicePicker binds it, which sets detectDeviceName).
panel.detectDeviceName = '';
panel._ssSavedDeviceName = prefs?.detectDeviceName || '';
// Per-panel detection-timing offset (ms) for an extra input device.
panel.detectVerifierOffsetMs = Number.isFinite(prefs?.detectVerifierOffsetMs) ? prefs.detectVerifierOffsetMs : 0;
panel.channelBtn.textContent = DETECT_CHANNEL_LABELS[panel.detectChannel];
if (panel.latVal) panel.latVal.textContent = (panel.detectVerifierOffsetMs > 0 ? '+' : '') + panel.detectVerifierOffsetMs + 'ms';
const hasNoteDetect = typeof window.createNoteDetector === 'function';
if (hasNoteDetect) {
panel.detectBtn.onclick = () => toggleDetect(panel);
panel.channelBtn.onclick = () => cycleDetectChannel(panel);
if (panel.latDown) panel.latDown.onclick = () => _ssNudgeOffset(panel, -5);
if (panel.latUp) panel.latUp.onclick = () => _ssNudgeOffset(panel, +5);
setupDevicePicker(panel);
} else {
panel.detectBtn.disabled = true;
panel.detectBtn.title = 'Note Detect plugin not loaded';
panel.detectBtn.style.opacity = '0.4';
panel.channelBtn.disabled = true;
panel.channelBtn.style.opacity = '0.4';
}

if (isLyricsMode) {
enterLyricsMode(panel);
} else if (isVizMode && vizInstalled) {
// Renderer was already installed before hw.init above; pass true to
// skip the redundant setRenderer call inside enterVizMode. If the
// factory threw earlier (vizInstalled=false), fall through to the
// plain-2D else-branch so the panel still gets a working highway.
enterVizMode(panel, savedVizPluginId, /* rendererPreInstalled */ true);
} else {
// Connect WebSocket. Pass an empty onSongInfo so core skips its
// default writes to shared HUD / audio / arrangement dropdown
// — otherwise every panel's song_info clobbers the main view.
// See got-feedback/feedBack#27.
hookPanelReady(panel);
panel.hw.connect(getWsUrl(currentFilename, arrIndex), { onSongInfo: () => {} });
}
}

Check notice on line 2857 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L2528-L2857

Complex Method

/**
* Toggle Detect.
Expand Down Expand Up @@ -2862,62 +2929,62 @@
* Populate + wire the per-panel input DEVICE picker. No-op (and the dropdown
* stays hidden) unless the desktop bridge exposes the Phase 2 multi-device API.
*/
async function setupDevicePicker(panel) {
const sel = panel.deviceSelect;
if (!sel) return;
// NEVER bind extra devices from a pop-out/follower window. The deviceKey map is
// module-local, so a popup independently allocating keys would trample the main
// window's engine slots (both grabbing deviceKey 1 for different hardware). The
// follower mirrors the main window's audio anyway — it leaves panels on Main
// (deviceKey 0) and shows no picker. Extra-device binding stays single-window.
if (FOLLOWER) { sel.style.display = 'none'; return; }
const audio = _ssAudio();
if (!audio || typeof audio.listInputDevices !== 'function'
|| typeof audio.bindInputDevice !== 'function') {
sel.style.display = 'none';
return;
}
let devices = null;
try { devices = await audio.listInputDevices(); } catch (_) { devices = null; }
// Bail if the split view was torn down / rebuilt while we awaited — this panel
// is detached (its <select> left the DOM), so binding a device for it below
// would orphan an extra-device slot no live panel owns (and pollute the
// keymap for the next session). Happens on slow startup + a quick stop/layout
// change before the picker finishes loading.
if (!active || !sel.isConnected) return;
if (!Array.isArray(devices) || devices.length === 0) {
sel.style.display = 'none';
return;
}
sel.innerHTML = '<option value="">Main</option>';
// Show every device the host enumerates — do NOT collapse same-named entries
// here. The engine's getBindableInputDevices() already returns a curated list
// (and is the single place that handles JUCE's name-based device identity); a
// second de-dup in the UI would only hide distinct interfaces it does expose.
// `seen` just records the offered names for the restore check below.
const seen = new Set();
for (const d of devices) {
const name = d && d.name;
if (!name) continue;
seen.add(name);
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name.length > 22 ? name.slice(0, 21) + '…' : name;
opt.title = name;
sel.appendChild(opt);
}
sel.style.display = '';
// Reveal the per-panel timing-offset nudge alongside the device picker.
if (panel.latWrap && typeof panel.latWrap.style !== 'undefined') panel.latWrap.style.display = 'inline-flex';
// Restore a persisted device selection (binds it — _ssApplyDevice sets
// detectDeviceName on success). Restore from the SAVED target, not the live
// detectDeviceName (which is empty until a bind confirms).
if (panel._ssSavedDeviceName && seen.has(panel._ssSavedDeviceName)) {
sel.value = panel._ssSavedDeviceName;
await _ssApplyDevice(panel, panel._ssSavedDeviceName);
}
sel.onchange = () => { _ssApplyDevice(panel, sel.value); };
}

Check notice on line 2987 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L2932-L2987

Complex Method

/**
* Resolve + bind the chosen device, set the panel's deviceKey, and rebind a live
Expand Down Expand Up @@ -2955,83 +3022,83 @@
* @param {*} panel
* @param {*} name
*/
async function _ssApplyDeviceBody(panel, name) {
const audio = _ssAudio();
const startStopGen = _ssRealStopGen; // detect a REAL stop racing this bind
// Wait for any prior session's extra-device unbinds to finish before we bind —
// otherwise binding a just-freed deviceKey can race a late-completing unbind
// that would then tear down this fresh binding. No-op when nothing is pending.
try { await _ssDeviceReleaseBarrier; } catch (_) { /* best-effort */ }
const prevName = panel.detectDeviceName || '';
if (!name) {
panel.detectDeviceKey = 0;
panel.detectDeviceName = '';
} else {
const key = _ssResolveDeviceKey(name);
if (key < 0) {
alert(`Multi-input supports up to ${SS_MAX_EXTRA_DEVICES} extra devices.`);
panel.deviceSelect.value = panel.detectDeviceName || '';
return;
}
if (audio && typeof audio.bindInputDevice === 'function') {
let bindErr = null;
try {
bindErr = await audio.bindInputDevice(key, name);
} catch (e) {
// A THROW is a failure too — don't fall through and adopt a device
// whose engine slot never opened (the detector would bind a dead
// deviceKey and silently score nothing).
console.warn('[splitscreen] bindInputDevice failed:', e);
bindErr = (e && e.message) ? e.message : 'bind failed';
}
// "" = opened; "already bound" = another panel opened it (fine).
if (bindErr && !/already bound/i.test(String(bindErr))) {
console.warn('[splitscreen] bindInputDevice:', bindErr);
alert('Could not open input device: ' + bindErr);
panel.deviceSelect.value = panel.detectDeviceName || '';
// Release the deviceKey _ssResolveDeviceKey just reserved for this
// name — otherwise a failed pick permanently consumes one of the
// limited extra-device slots. Clear our OWN in-flight marker first:
// _ssMaybeUnbindDevice's "still in use" guard checks _ssPendingDeviceName,
// which is still `name` here, so it would match this very panel and
// skip the unbind, leaking the key.
panel._ssPendingDeviceName = null;
await _ssMaybeUnbindDevice(name);
return;
}
}
// The bind may have taken a while; if the split view was torn down /
// rebuilt meanwhile, this panel is detached. Don't write its state or
// re-toggle a dead detector. Whether to RELEASE the device we just bound
// depends on WHY we're detached: a REAL stop (gen bumped) ran its unbind-all
// BEFORE this late bind, so release the orphaned device by key (the keymap
// may already be cleared). A REBUILD (gen unchanged) intentionally keeps the
// bindings for the next session — likely already reused by the rebuilt
// panel — so leave it bound, or we'd kill the new panel's source.
if (!active || !panel.deviceSelect || !panel.deviceSelect.isConnected) {
if (_ssRealStopGen !== startStopGen) {
const a2 = _ssAudio();
if (a2 && typeof a2.unbindInputDevice === 'function') {
const p = Promise.resolve(a2.unbindInputDevice(key)).catch(() => {});
_ssDeviceReleaseBarrier = Promise.allSettled([_ssDeviceReleaseBarrier, p]);
await p;
}
if (_ssDeviceKeyByName.get(name) === key) _ssDeviceKeyByName.delete(name);
}
return;
}
panel.detectDeviceKey = key;
panel.detectDeviceName = name;
}
// Re-toggle to rebind the source to the new device if detect is live.
if (panel.detector) { toggleDetect(panel); toggleDetect(panel); }
// Release the device this panel just left if no other panel still uses it
// (frees the engine slot + deviceKey; stops stale binds accumulating). AWAIT
// it so this chained apply doesn't complete — letting the next queued change
// start — until the old device is actually released (its key safely freed).
if (prevName && prevName !== name) await _ssMaybeUnbindDevice(prevName);
savePanelPrefs();
}

Check notice on line 3101 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L3025-L3101

Complex Method

/**
* Switch Panel Arrangement.
Expand All @@ -3049,91 +3116,91 @@
/**
* Teardown Panels.
*/
function teardownPanels() {
// Flip active + notify focus listeners up-front. All callers
// (stopSplitScreen, rebuildLayout, popOutPanel, _redockPanel) need
// active=false so a follow-up startSplitScreen() passes its
// `_starting || active` re-entrancy guard. Centralising the flip
// here removes the foot-gun of every restart path remembering to
// clear it manually. Plugin destroy() handlers below run against
// the inactive world view, which is what they expect when they
// read isActive() during cleanup.
active = false;
_emitFocusChange();
// A REBUILD (resize / arrangement switch) tears down then immediately
// restarts, so it must KEEP the extra-device bindings + the main-detector
// suppression — releasing + rebinding a tick later races the async unbind
// (stale-device hand-off) and blips the main HUD. Only a REAL stop does the
// full cleanup below. (startSplitScreen skips its capture/suppress when
// _ssDetectSuppressed is still set, so the kept state isn't clobbered.)
if (!_ssTransientTeardown) {
_ssRealStopGen++; // a real stop — in-flight binds use this to self-release
// Split mode ending — release every extra input device the panels bound,
// freeing the engine slots + deviceKeys so a later session starts clean.
// Track the (async) unbinds in the release barrier so the NEXT session's
// _ssApplyDevice waits for them to finish before rebinding a freed key —
// otherwise a late unbind tears down the freshly-rebound device.
{
const audio = _ssAudio();
// Chain ONTO the existing barrier — never replace it — so an unbind a
// recent panel-switch already queued isn't dropped (its key could be
// rebound by the next session before that older unbind completes).
const pending = [_ssDeviceReleaseBarrier];
if (audio && typeof audio.unbindInputDevice === 'function')
for (const key of _ssDeviceKeyByName.values())
try { pending.push(Promise.resolve(audio.unbindInputDevice(key)).catch(() => {})); } catch (_) { /* best-effort */ }
_ssDeviceReleaseBarrier = Promise.allSettled(pending);
_ssDeviceKeyByName.clear();
}
// Stop suppressing the singleton + restore it if split had silenced it.
_ssSetDefaultSuppressed(false);
_ssDetectSuppressed = false;
if (_ssMainDetectWasOn) {
try {
const nd = (typeof window !== 'undefined') ? window.noteDetect : null;
if (nd && typeof nd.enable === 'function') nd.enable();
} catch (_) { /* best-effort */ }
_ssMainDetectWasOn = false;
}
}
for (const p of panels) {
_leavePanelContext(p);
if (p.detector) {
p.detector.destroy();
p.detector = null;
}
if (p.lyricsPane) {
p.lyricsPane.destroy();
p.lyricsPane = null;
}
if (p.lyricsOverlay) {
p.lyricsOverlay.destroy();
p.lyricsOverlay.el.remove();
p.lyricsOverlay = null;
}
if (p.chordsOverlay) {
p.chordsOverlay.destroy();
p.chordsOverlay = null;
}
if (p.vizMode) {
p.hw.setRenderer(null);
p.vizMode = null;
p.vizRenderer = null;
}
p.hw.stop();
}
panels = [];
// A transient teardown (rebuildLayout, pop-out, dock) restarts
// immediately and restores each surviving panel's playerId from
// savedPrefs — resetting the counter here would let a freshly
// auto-assigned id (a genuinely new panel) collide with one being
// restored. Only a REAL stop clears it.
if (!_ssTransientTeardown) nextPlayerNumber = 1;
if (wrap) {
wrap.remove();
wrap = null;
}
}

Check notice on line 3203 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L3119-L3203

Complex Method

// ══════════════════════════════════════════════════════════════════════
// Pop-out / dock helpers
Expand Down Expand Up @@ -3261,140 +3328,140 @@
* The panel is removed from the main layout once the popup is opened
* (slot collapses; rebuildLayout reflows remaining panels).
*/
function popOutPanel(panel) {
if (!currentFilename) return;
// A start is in flight (e.g. this is the very first pop-out of the
// session and startSplitScreen is still awaiting _vizPluginsReady).
// teardownPanels() below would rip out that half-built layout out
// from under it — bail instead of racing it, same hazard rebuildLayout()
// and _redockPanel() already guard against via `_starting`.
if (_starting) {
_showMainToast('Split screen is still starting — try popping out again in a moment.');
return;
}
const idx = panels.indexOf(panel);
if (idx === -1) return;
if (typeof BroadcastChannel !== 'function') {
_showMainToast('Pop-out requires a browser that supports BroadcastChannel.');
return;
}
const cfg = _captureFollowerConfig(panel);
const popupId = _newPopupId();

const url = new URL(window.location.origin + '/');
const sp = url.searchParams;
sp.set('ssFollower', '1');
sp.set('popupId', popupId);
sp.set('filename', currentFilename);
sp.set('arrangement', String(cfg.arrangement));
// Panel slot index (0..3) at pop-out time, so a follower window can
// resolve which panel it is — e.g. Camera Director applies that panel's
// camera in the popup. Purely additive; ignored by consumers that don't read it.
sp.set('panelIndex', String(idx));
sp.set('name', panel.name || ('P' + (idx + 1)));
sp.set('mode', cfg.mode);
sp.set('inverted', String(cfg.inverted));
sp.set('lefty', String(cfg.lefty || 0));
sp.set('lyrics', cfg.lyrics ? '1' : '0');
sp.set('barHidden', cfg.barHidden ? '1' : '0');
sp.set('detectChannel', cfg.detectChannel || 'mono');
sp.set('detectDeviceName', cfg.detectDeviceName || '');
sp.set('detectVerifierOffsetMs', String(cfg.detectVerifierOffsetMs || 0));
if (Number.isFinite(cfg.mastery)) sp.set('mastery', String(cfg.mastery));
if (cfg.vizId) sp.set('vizId', cfg.vizId);

const popup = window.open(url.toString(), popupId, 'popup,width=1280,height=420');
if (!popup) {
_showMainToast('Pop-out blocked by the browser. Allow popups for this site and try again.');
return;
}
// Sever popup.opener rather than passing 'noopener' in the features
// string: per spec, 'noopener' makes window.open() itself return null
// even on a successful open, which would break every downstream use
// of the handle below (popups.set, the broadcaster, the closed-popup
// reap via e.popup.closed). Setting popup.opener = null after the
// non-null check achieves the same defense-in-depth goal (no live
// opener back-reference) without discarding the WindowProxy the rest
// of this function depends on. Same-origin today (url is built from
// window.location.origin with locally-constructed params, never
// attacker-supplied), but this is what stops it from becoming a
// reverse-tabnabbing vector the moment that ever changes.
popup.opener = null;
// Track the popup (incl. its window handle so the broadcaster can reap
// it if it dies without firing beforeunload).
popups.set(popupId, { popup, originalConfig: cfg });
// Force the next broadcaster tick to re-send the current time even if
// the main audio is paused (== unchanged), so this freshly-opened
// popup gets a playhead value instead of sitting at 0.
_lastBroadcastTime = null;

// Open the channel in the main window so we can broadcast time and
// listen for the popup's docked / closed messages. Every `time`
// message carries the current `playing` flag, so the fresh popup
// learns the play/pause state from the first one it receives (the
// _lastBroadcastTime reset above forces that to be sent promptly).
_ensureMainBroadcasterAndListener();
_startPopupBroadcaster();

// Remove this panel from the live layout. The remaining panels are
// rebuilt; if popping leaves only 1 panel we stop split entirely and
// the main view goes back to its default highway. Otherwise we
// downgrade to the nearest-fit layout so we don't leave an empty
// default slot in the grid (see _bestFitLayout below).
const wasActive = active;
const remaining = panels.filter(p => p !== panel);
const savedPrefs = remaining.map(panelToPrefs);

if (wasActive && savedPrefs.length === 0) {
// Single-panel split (rare) — pop out leaves nothing.
stopSplitScreen();
return;
}
if (wasActive && savedPrefs.length === 1) {
// Last panel popped — go back to the default highway view.
teardownPanels();
stopSplitScreen();
return;
}
// 2+ remaining. Downgrade to the smallest layout that still fits
// everyone left, so we don't leave an empty default slot — e.g.
// five (5) -> quad (4), quad (4) -> tri-top (3), six (6) -> five (5).
// Was hardcoded to 'top-bottom' from back when that was the only
// downgrade target; that collapsed every layout above quad straight
// to 2 panels instead of the nearest fit (splitscreen#27). Reuses
// the same _bestFitLayout() the dock-back path already relies on.
if (wasActive && LAYOUTS[layout] && savedPrefs.length < LAYOUTS[layout].panels) {
layout = _bestFitLayout(savedPrefs.length);
try { localStorage.setItem('splitscreenLayout', layout); } catch (_) {}
}
if (wasActive) {
// The popped-out panel leaves the main window (it becomes a follower that
// binds nothing). The transient teardown below KEEPS remaining bindings, so
// it won't free this panel's device — release it explicitly if no REMAINING
// panel shares it, or its deviceKey leaks (and after a few pop-outs new binds
// hit the max-device alert). It re-binds from saved prefs on dock-back.
const poppedDev = panel.detectDeviceName;
if (poppedDev && !remaining.some(p => p && p.detectDeviceName === poppedDev)) {
const key = _ssDeviceKeyByName.get(poppedDev);
if (key != null) {
_ssDeviceKeyByName.delete(poppedDev);
const audio = _ssAudio();
if (audio && typeof audio.unbindInputDevice === 'function') {
const p = Promise.resolve(audio.unbindInputDevice(key)).catch(() => {});
_ssDeviceReleaseBarrier = Promise.allSettled([_ssDeviceReleaseBarrier, p]);
}
}
panel.detectDeviceName = '';
}
// Immediate restart — keep extra-device bindings + main-detector
// suppression (a transient teardown), same as rebuildLayout(). try/finally
// so a throw can't leave the flag stuck true (which would make the next
// REAL stop skip device + main-detector cleanup).
_ssTransientTeardown = true;
try { teardownPanels(); } finally { _ssTransientTeardown = false; }
startSplitScreen(null, savedPrefs);
}
}

Check notice on line 3464 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L3331-L3464

Complex Method

/**
* Called from the popup when the user clicks Dock. Posts state back to the
Expand Down Expand Up @@ -3482,237 +3549,237 @@
* @param {*} existingArrangements
* @param {*} savedPrefs
*/
async function startSplitScreen(existingArrangements, savedPrefs) {
// Re-entrancy guard: prevent concurrent starts from double-clicks,
// layout rebuilds, or auto-reactivate firing while a start is in flight.
if (_starting || active) return;
_starting = true;
try {
await _vizPluginsReady;

const info = highway.getSongInfo();
if (info && info.arrangements) {
arrangements = info.arrangements;
}
if (arrangements.length === 0) return;

// If no explicit arrangements or prefs passed, try loading from storage
if (!existingArrangements && !savedPrefs) {
savedPrefs = migratePanelPrefs(loadPanelPrefs());
}

const cfg = LAYOUTS[layout];
const container = createWrap();
applyLayoutStyle(container, layout);

// Determine arrangements for each panel
let arrDefaults;
if (existingArrangements && existingArrangements.length >= cfg.panels) {
arrDefaults = existingArrangements.slice(0, cfg.panels);
} else if (savedPrefs && savedPrefs.length > 0) {
arrDefaults = [];
for (let i = 0; i < cfg.panels; i++) {
const pref = savedPrefs[i % savedPrefs.length];
if (pref && pref.arrName === LYRICS_VALUE) {
arrDefaults.push(0);
} else if (pref && pref.arrName?.startsWith(VIZ_PREFIX + ':')) {
const parts = pref.arrName.split(':');
const vizArrName = parts.slice(2).join(':');
const vizIdx = resolveArrIndex(vizArrName);
arrDefaults.push(vizIdx >= 0 ? vizIdx : 0);
} else {
const idx = pref ? resolveArrIndex(pref.arrName) : -1;
arrDefaults.push(idx >= 0 ? idx : getDefaultArrangements(1)[0]);
}
}
} else {
arrDefaults = getDefaultArrangements(cfg.panels);
}

// Flip active BEFORE the panel-init loop. initPanel may install a
// viz renderer (e.g. piano) whose init() calls back into
// window.slopsmithSplitscreen.panelChromeFor() / settingsAnchorFor().
// Those gate on isActive(); if active flips true only after the loop,
// the renderer mounts to #player (main-player fast path) on the first
// entry and is stuck full-screen until the next start cycle.
active = true;
try { localStorage.setItem('splitscreenActive', 'true'); } catch (_) {}
focusedPanelIdx = 0;

// Silence the MAIN-PLAYER note detector while split mode owns detection —
// otherwise its HUD + score render on top of P1 (the panel reusing the
// main-player slot), showing a confusing second percentage/streak. Remember
// whether it was on so teardown can restore it.
try {
// Suppress the singleton (blocks its auto-enable / re-arm AND tears down an
// already-running session). Skip when split ALREADY owns detection — a
// rebuild keeps suppression on across teardown, and re-capturing here would
// read the (now-disabled) singleton as off and lose the real "was on" state
// we must restore on the eventual real stop. Gate on our OWN flag, not
// note_detect's internal one. CAPTURE the on-state BEFORE suppressing, since
// _ssSetDefaultSuppressed(true) now disables the singleton itself.
if (!_ssDetectSuppressed) {
const nd = (typeof window !== 'undefined') ? window.noteDetect : null;
_ssMainDetectWasOn = !!(nd && typeof nd.isEnabled === 'function' && nd.isEnabled());
_ssSetDefaultSuppressed(true);
_ssDetectSuppressed = true;
}
} catch (_) { /* keep prior _ssMainDetectWasOn */ }

// Size the wrap NOW so panelDivs have a real rect during initPanel.
// sizeCanvases() runs at end of start, but viz renderers (piano,
// drums) measure panelChrome.clientWidth/Height in their init() —
// a wrap with no `bottom` set has height:auto = 0 → panelDiv 50%
// of 0 = 0 → renderer's bitmap = 0x0 → CSS upscales = pixelated.
const initialChrome = document.getElementById('player-footer')
|| document.getElementById('player-controls');
const initialControlsH = initialChrome ? initialChrome.offsetHeight : 0;
container.style.bottom = initialControlsH + 'px';

for (let i = 0; i < cfg.panels; i++) {
const parts = createPanel(i, container, layout);
const hw = createHighway();
const panel = Object.assign({ hw, arrIndex: 0 }, parts);

// Override resize BEFORE init — highway's default sizes to full window,
// which clobbers all panels to overlap. Size to parent panel instead.
// `measured`: optional precomputed { rect, barH } — see the comment on
// the identical override in recreatePanelHighway() for why (batches
// sizeCanvases()'s reads ahead of every panel's writes).
hw.resize = function (measured) {
const c = panel.canvas;
if (!c) return;
const rect = measured ? measured.rect : panel.panelDiv.getBoundingClientRect();
const barH = measured ? measured.barH : (panel.bar.style.display === 'none' ? 0 : (panel.bar.offsetHeight || 28));
const w = rect.width;
const h = Math.max(0, rect.height - barH);
c.style.width = w + 'px';
c.style.height = h + 'px';
const scale = hw.getRenderScale();
c.width = Math.round(w * scale);
c.height = Math.round(h * scale);
};

panels.push(panel);
const panelPrefs = savedPrefs ? savedPrefs[i % savedPrefs.length] : null;
// Restore this slot's stable player identity across a rebuild
// (layout change, pop-out/dock) so profile/mastery/scoring state
// keyed by player_id stays attached to the same surviving panel
// instead of being renumbered. Only trust it when savedPrefs has
// exactly one entry per panel — the modulo-reuse above means a
// shorter savedPrefs array (e.g. growing from 2 panels to 4) would
// otherwise hand the same playerId to more than one new panel.
if (panelPrefs?.playerId && savedPrefs.length >= cfg.panels) {
panel.playerId = panelPrefs.playerId;
const n = parseInt(String(panelPrefs.playerId).replace(/^player-/, ''), 10);
if (Number.isFinite(n) && n >= nextPlayerNumber) nextPlayerNumber = n + 1;
}
initPanel(panel, arrDefaults[i], panelPrefs);
panel.barToggleBtn.onclick = () => togglePanelBar(panel);
if (panelPrefs?.barHidden) togglePanelBar(panel);
// Restore the panel's saved name (falls back to the positional default).
panel.name = (panelPrefs && panelPrefs.name) || `P${i + 1}`;
if (panel.nameInput) panel.nameInput.value = panel.name;
}
_emitPanelsChanged();

// Hide default highway canvas, ensure controls stay on top and at bottom.
// Core detects the hide via canvas.offsetParent === null (slopsmith#246):
// it pauses the main highway's rAF draw AND emits `highway:visibility`
// on window.slopsmith. Viz renderers that mount sibling DOM — e.g. 3D
// Highway's .h3d-wrap overlay, a sibling of #highway that display:none
// on the canvas alone doesn't cover — subscribe to that event and hide
// their own overlays. Splitscreen no longer reaches into other plugins'
// DOM to do this; it just hides #highway and lets the contract handle
// the rest (on stop, restoring #highway re-emits visible → overlays
// re-show themselves).
const defaultCanvas = document.getElementById('highway');
if (defaultCanvas) defaultCanvas.style.display = 'none';
const controls = document.getElementById('player-controls');
// Legacy only: forcing position/z-index/margin on the classic controls
// bar keeps it above the highway canvas. In v3 #player-controls is the
// host's auto-hiding transport (its own positioning/z-index), so this
// override would corrupt the v3 chrome — skip it there.
if (controls && !_ssIsV3()) {
controls.style.position = 'relative'; // Required for z-index to work
controls.style.zIndex = '10';
controls.style.marginTop = 'auto';
}

sizeCanvases();
// Paint focus border + notify any listeners that registered during
// the per-panel init pass (piano subscribes from its init()).
_applyFocusBorder();
_emitFocusChange();
updateBtn();
setRedundantControlsHidden(true);
// HUD: visible while loaded; fades out when audio begins playback.
const audio = document.getElementById('audio');
if (audio && !audio.paused) fadeOutHud();
else showHud();
savePanelPrefs();

if (localStorage.getItem('splitscreenControlsHidden') === 'true') toggleControlsVisibility();

// Hook into the time sync loop
startTimeSync();

// Fade the wrap in now that all panels are built and sized — matches
// the fade pattern used elsewhere in this codebase (_showMainToast,
// player HUD) so entering split isn't a jarring hard cut.
if (wrap) {
wrap.style.transition = 'opacity 0.15s ease-in';
wrap.style.opacity = '1';
}
} catch (err) {
// Rollback any partial state so the UI doesn't get stuck with
// active=true, default highway hidden, and no panels — that's
// the worst case (nothing renders, Split button thinks split is
// running, toggle is now a no-op). teardownPanels handles the
// active flip + plugin destroy; mirror stopSplitScreen for the
// rest of the chrome resets so a partially-applied "split mode"
// doesn't survive the failure.
console.error('startSplitScreen failed:', err);
teardownPanels();
setRedundantControlsHidden(false);
restoreHud();
const defaultCanvas = document.getElementById('highway');
if (defaultCanvas) defaultCanvas.style.display = '';
const controls = document.getElementById('player-controls');
if (controls) {
if (controlsHidden) controls.style.display = '';
// Legacy only — never write transport styling in v3 (matches the
// gated override in startSplitScreen / injectBtn).
if (!_ssIsV3()) {
controls.style.zIndex = '10';
controls.style.marginTop = '';
}
}
controlsHidden = false;
if (floatBtn) floatBtn.style.display = 'none';
updateBtn();
stopTimeSync();
} finally {
_starting = false;
// Drain redock requests that arrived mid-start (popup's `docked`
// message). Each _redockPanel re-enters startSplitScreen (setting
// _starting), whose own finally drains the rest — so stop here as
// soon as _starting flips, to avoid re-queuing into an infinite
// loop. Do this BEFORE the rebuild drain so a queued layout change
// reflows the final panel set including the redocked one.
while (_pendingRedocks.length && !_starting) {
const r = _pendingRedocks.shift();
_redockPanel(r.popupId, r.finalState, r.finalStates);
}
// Drain a queued rebuild from rebuildLayout. Only fire if the
// session is still active — a failed start above already did
// a full teardown, in which case there's nothing to rebuild.
if (_pendingRebuild) {
_pendingRebuild = false;
if (active) rebuildLayout();
}
}
}

Check notice on line 3782 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L3552-L3782

Complex Method

/**
* Stop Split Screen.
Expand Down Expand Up @@ -5219,57 +5286,57 @@
* BroadcastChannel (popup windows) and the LAN relay WebSocket (remote
* viewers). Reads live module state, so it survives layout rebuilds.
*/
function _followerBusHandler(msg) {
if (_followerOrphaned || !msg) return;
const remote = !!(FOLLOWER && FOLLOWER.remote);
if (msg.type === 'time' && Number.isFinite(msg.t)) {
_onFollowerTimeMessage(msg.t, msg.playing);
} else if (msg.type === 'playstate') {
_onFollowerPlayState(!!msg.playing);
} else if (msg.type === 'main-closed') {
if (remote) {
// For a LAN viewer, the host window going away is often just a
// reload/relaunch (the share auto-resumes on the same key).
// Hold in the reconnectable waiting state; only an explicit
// `share-ended` is terminal.
_followerPlaying = false;
_showRemoteWaiting('Host closed — waiting for it to come back…');
} else {
_onFollowerOrphaned();
}
} else if (msg.type === 'dock-all') {
// Main window's "Dock all" button asked every live popup to dock
// itself. Reuse the exact single-panel dock path (dockFollowerPanel)
// so this popup's live state — not a stale pop-out-time snapshot —
// is what gets redocked; remote LAN viewers have nothing to dock
// into and dockFollowerPanel already no-ops for them.
if (!remote && panels.length) dockFollowerPanel(panels[0]);
} else if (msg.type === 'share-ended') {
if (remote) {
_onFollowerOrphaned('Sharing ended',
'The host stopped sharing this session — you can close this tab.');
}
} else if (msg.type === 'song-changed' && msg.filename && msg.filename !== currentFilename) {
_handleFollowerSongChange(msg.filename);
} else if (msg.type === 'config' && remote && msg.filename && msg.popupId === _remotePopupId) {
// Config replies also arrive after a relay reconnect (we re-hello
// on every open) and while waiting out a host restart (the hello
// poll keeps running in that state): treat as recovery — dismiss
// the waiting overlay, follow any song change we missed, re-anchor
// the clock. Guarded to OUR popupId: the relay is a broadcast
// room, so replies to other viewers' hellos also arrive here.
_hideRemoteWaiting();
if (msg.filename !== currentFilename) _handleFollowerSongChange(msg.filename);
if (Number.isFinite(msg.t)) _onFollowerTimeMessage(msg.t, msg.playing);
}
// Any live host traffic while the "waiting for host" overlay is up
// proves the host is back (it may resume playing without anyone
// sending a fresh hello) — drop the overlay.
if (remote && _remoteWaitingShown
&& (msg.type === 'time' || msg.type === 'playstate' || msg.type === 'song-changed')) {
_hideRemoteWaiting();
}
}

Check notice on line 5339 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L5289-L5339

Complex Method

// Cached reference to the popup's <audio> element so the song-change
// handler can re-assert mute/pause + re-shim without re-querying.
Expand Down Expand Up @@ -5512,146 +5579,146 @@
* defaults via getDefaultArrangements. Replaces the older single-panel
* buildFollowerPanel so the popup can host any of the standard layouts.
*/
function buildFollowerLayout(cfgs, layoutKey) {
layoutKey = FOLLOWER_LAYOUT_PANELS[layoutKey] ? layoutKey : 'follower';
_followerLayoutKey = layoutKey;
const panelCount = FOLLOWER_LAYOUT_PANELS[layoutKey];

const info = highway.getSongInfo();
if (info && info.arrangements) arrangements = info.arrangements;

// Build the full-viewport wrap. Reuse the #splitscreen-wrap id so
// any selectors elsewhere find it identically. We leave room at
// the bottom for the follower toolbar.
//
// Block layout for single-panel mode, flex for multi-panel. With
// a single child at width/height: 100%, the flexbox algorithm
// doesn't reliably resolve the main-axis size — height: 100%
// can collapse to the child's content height. That made the
// panelDiv bounding rect 0-tall on first measure, the bar
// (position:absolute; bottom:0) got clipped by the panel's
// overflow:hidden, and the per-panel control bar appeared
// missing. Block positioning (matches the original
// buildFollowerPanel behavior) sizes height: 100% against the
// position:fixed parent's definite dimensions cleanly. Multi-
// panel layouts use 50% sizes which the flex algorithm resolves
// fine, so they keep the flex container.
const followerWrap = document.createElement('div');
followerWrap.id = 'splitscreen-wrap';
followerWrap.style.cssText =
'position:fixed;top:0;left:0;right:0;bottom:' + FOLLOWER_TOOLBAR_H + 'px;' +
'background:#000;z-index:9999;';
if (layoutKey === 'top-bottom') {
followerWrap.style.display = 'flex';
followerWrap.style.flexDirection = 'column';
} else if (layoutKey === 'left-right') {
followerWrap.style.display = 'flex';
followerWrap.style.flexDirection = 'row';
} else if (layoutKey === 'quad') {
// CSS grid, not flex-wrap — same non-interactive-panel bug as the
// main window's wrap (see applyLayoutStyle): followerWrap's height
// comes from a position inset (bottom:FOLLOWER_TOOLBAR_H), not an
// explicit height, which some browsers resolve as indefinite for
// %-height flex items in a wrapped row.
followerWrap.style.display = 'grid';
followerWrap.style.gridTemplateColumns = 'repeat(2,1fr)';
followerWrap.style.gridTemplateRows = 'repeat(2,1fr)';
}
// else: single (follower) — leave as block layout (no flex).
document.body.appendChild(followerWrap);
wrap = followerWrap;

// Smart-default arrangement indices for slots beyond the explicit
// cfgs (e.g. when user widens 1 → 4, slots 1..3 get lead/rhythm/bass
// assignments via the same helper main uses).
const defaultArrs = getDefaultArrangements(panelCount);

for (let i = 0; i < panelCount; i++) {
// Pick the layoutKey passed to createPanel so panel sizing is
// correct: 'follower' for single, otherwise the layout name.
const panelLayoutKey = (panelCount === 1) ? 'follower' : layoutKey;
const parts = createPanel(i, followerWrap, panelLayoutKey);
const hw = createHighway();
const panel = Object.assign({ hw, arrIndex: 0 }, parts);

// Same hw.resize override pattern startSplitScreen() uses. `measured`:
// optional precomputed { rect, barH } — see recreatePanelHighway()'s
// comment; used by the window-resize handler below to batch reads
// ahead of writes across every panel in a multi-panel popup.
hw.resize = function (measured) {
const c = panel.canvas;
if (!c) return;
const rect = measured ? measured.rect : panel.panelDiv.getBoundingClientRect();
const barH = measured ? measured.barH : (panel.bar.style.display === 'none' ? 0 : (panel.bar.offsetHeight || 28));
const w = rect.width;
const h = Math.max(0, rect.height - barH);
c.style.width = w + 'px';
c.style.height = h + 'px';
const scale = hw.getRenderScale();
c.width = Math.round(w * scale);
c.height = Math.round(h * scale);
};

panels.push(panel);

// Pick this slot's config: explicit if cfgs has it, else smart default.
const cfg = cfgs[i] || {
arrangement: defaultArrs[i] || 0,
mode: '2d',
inverted: 0,
lefty: 0,
mastery: 1,
};
const arrIdx = (cfg.arrangement >= 0 && cfg.arrangement < arrangements.length)
? cfg.arrangement : 0;
// Restore this slot's stable player identity across a follower
// rebuild, same as the local-window restore at startSplitScreen's
// savedPrefs loop — keeps nextPlayerNumber from drifting once
// follower rebuilds stopped resetting it (_ssTransientTeardown).
if (cfg.playerId) {
panel.playerId = cfg.playerId;
const n = parseInt(String(cfg.playerId).replace(/^player-/, ''), 10);
if (Number.isFinite(n) && n >= nextPlayerNumber) nextPlayerNumber = n + 1;
}
initPanel(panel, arrIdx, _followerCfgToPrefs(cfg, arrIdx));

// Wire the panel's bar-toggle button. startSplitScreen() does
// this in main; follower-mode panels need the same hookup or
// the per-panel ▾ Bar button is dead.
panel.barToggleBtn.onclick = () => togglePanelBar(panel);

// The popped-out (primary) panel keeps the name it had in the main
// window (carried in the pop-out URL); extra panels the follower
// split off get positional defaults.
panel.name = (i === 0 && FOLLOWER && FOLLOWER.name) ? FOLLOWER.name : `P${i + 1}`;
if (panel.nameInput) panel.nameInput.value = panel.name;
}

active = true;
for (const p of panels) p.hw.resize();

// Announce the follower's panel set so per-panel plugins (e.g. Camera
// Director) pick up this window's panels — and any re-split of it —
// promptly rather than on their next poll. getPanels() reflects `panels`
// now that `active` is true, so a split follower exposes each sub-panel.
_emitPanelsChanged();

// Subscribe to the sync transport for time / playstate / song-change
// / main-closed. Popups use the BroadcastChannel (re-assigning
// `onmessage` on each rebuild replaces the prior handler — no listener
// stacking); remote (LAN) viewers already dispatch relay messages into
// _followerBusHandler, which reads live module state and needs no
// re-subscription on rebuild.
if (!(FOLLOWER && FOLLOWER.remote)) {
const ch = _ssChannel();
if (ch) {
ch.onmessage = (ev) => _followerBusHandler(ev.data || {});
}
}
// Make sure the extrapolation loop is running (cheap if already started
// from bootFollowerMode; also covers a hypothetical rebuild before boot).
_startFollowerInterp();
}

Check notice on line 5721 in screen.js

View check run for this annotation

codefactor.io / CodeFactor

screen.js#L5582-L5721

Complex Method

// Bottom toolbar inside the popup window: layout picker + dock-all.
// Built once per popup, the layout selector triggers rebuild of the
Expand Down
109 changes: 109 additions & 0 deletions tests/screen.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1741,6 +1741,8 @@ function makeVizElementStub(tag) {
textContent: '',
value: '',
checked: false,
disabled: false,
title: '',
min: '',
max: '',
step: '',
Expand Down Expand Up @@ -2216,6 +2218,113 @@ test('buildVizPopover creates a checkbox for toggle controls and fires onchange'
assert.equal(global.localStorage.getItem('h3d_bg_panel0_cameraLockLow'), 'false');
});

// ── buildVizPopover: dependsOn greying ───────────────────────────────────────

// makeVizElementStub discards addEventListener, so a `change` listener wired by
// buildVizPopover would never run. This variant records listeners and can fire
// them, which is what the dependsOn wiring needs.
function makeListeningVizElement(tag) {
const el = makeVizElementStub(tag);
const listeners = {};
el.addEventListener = (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); };
el.fire = (type) => { (listeners[type] || []).forEach((fn) => fn()); };
return el;
}

// Build the popover for `controls` and return { key: { row, input } }.
// `saved` pre-sets per-panel values so a toggle can start ticked.
function buildDepsPopover(controls, saved = {}) {
const mod = freshVizPlugin();
mod._setArrangementsForTest([{ name: 'Lead' }]);
window.feedBackViz_highway_3d = function () {};
window.feedBackViz_highway_3d.panelControls = controls;
for (const [key, v] of Object.entries(saved)) global.localStorage.setItem('h3d_bg_panel0_' + key, String(v));
const pop = makeVizElementStub('div');
const panel = { vizPopover: pop, vizMode: 'highway_3d' };
mod._setPanelsForTest([panel]);
const rows = [];
global.document.createElement = (tag) => {
const el = makeListeningVizElement(tag);
if (tag === 'label') rows.push(el);
return el;
};
mod.buildVizPopover(panel, 'highway_3d');
const out = {};
controls.forEach((ctl, i) => {
const row = rows[i];
out[ctl.key] = { row, input: row.children.find((c) => c.tagName === 'INPUT' || c.tagName === 'SELECT') };
});
return out;
}

const DEP_TOGGLE = { key: 'lock', label: 'Lock', type: 'toggle', default: false };
const DEP_RANGE = { key: 'zoom', label: 'Zoom', type: 'range', default: 0.5, dependsOn: 'lock' };

test('a control with dependsOn is disabled and dimmed until its toggle is on, and follows later clicks', () => {
const ui = buildDepsPopover([DEP_TOGGLE, DEP_RANGE]);
assert.equal(ui.zoom.input.disabled, true, 'starts disabled while the toggle is off');
assert.equal(ui.zoom.row.style.opacity, '0.4');
assert.match(ui.zoom.row.title, /Requires "Lock"/);
assert.equal(ui.lock.input.disabled, false, 'the gating toggle itself stays usable');

ui.lock.input.checked = true;
ui.lock.input.fire('change');
assert.equal(ui.zoom.input.disabled, false);
assert.equal(ui.zoom.row.style.opacity, '');
assert.equal(ui.zoom.row.title, '');

ui.lock.input.checked = false;
ui.lock.input.fire('change');
assert.equal(ui.zoom.input.disabled, true, 'greys out again when the toggle is cleared');
});

test('a dependsOn control starts enabled when its toggle was saved on', () => {
const ui = buildDepsPopover([DEP_TOGGLE, DEP_RANGE], { lock: true });
assert.equal(ui.zoom.input.disabled, false);
assert.equal(ui.zoom.row.style.opacity, '');
});

test('a missing or non-toggle dependsOn target is ignored and the control stays usable', () => {
const ui = buildDepsPopover([
{ key: 'a', label: 'A', type: 'range', default: 0.5, dependsOn: 'nope' },
{ key: 'r', label: 'R', type: 'range', default: 0.5 },
{ key: 'b', label: 'B', type: 'range', default: 0.5, dependsOn: 'r' },
]);
assert.equal(ui.a.input.disabled, false, 'unknown target');
assert.equal(ui.b.input.disabled, false, 'range is not a valid gate');
});

test('a control that depends on itself is not locked', () => {
const ui = buildDepsPopover([{ key: 'self', label: 'Self', type: 'toggle', default: false, dependsOn: 'self' }]);
assert.equal(ui.self.input.disabled, false, 'otherwise it could never be ticked again');
assert.ok(!ui.self.row.style.opacity, 'and not dimmed');
});

test('a dependsOn cycle leaves every member usable', () => {
const ui = buildDepsPopover([
{ key: 'a', label: 'A', type: 'toggle', default: false, dependsOn: 'b' },
{ key: 'b', label: 'B', type: 'toggle', default: false, dependsOn: 'a' },
]);
assert.equal(ui.a.input.disabled, false);
assert.equal(ui.b.input.disabled, false);
});

test('gating is transitive: a control behind a greyed-out gate is greyed out too', () => {
const ui = buildDepsPopover([
{ key: 'c', label: 'C', type: 'toggle', default: false },
{ key: 'b', label: 'B', type: 'toggle', default: false, dependsOn: 'c' },
{ key: 'a', label: 'A', type: 'range', default: 0.5, dependsOn: 'b' },
], { b: true });
// B is saved on, but its own gate C is off, so A must not be live.
assert.equal(ui.b.input.disabled, true);
assert.equal(ui.a.input.disabled, true, 'checked-but-unreachable gate must not enable its dependent');

ui.c.input.checked = true;
ui.c.input.fire('change');
assert.equal(ui.b.input.disabled, false);
assert.equal(ui.a.input.disabled, false, 'follows once the root gate is on');
});

test('buildVizPopover re-fires the plugin setter when its onchange handler runs', () => {
const mod = freshVizPlugin();
mod._setArrangementsForTest([{ name: 'Lead' }]);
Expand Down
Loading