diff --git a/README.md b/README.md index 2747b72..7a00fb8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ A plugin for [Slopsmith](https://github.com/got-feedback/feedBack) that shows 2 - **Per-panel visualization picker** — each panel can independently run any installed `slopsmithViz` plugin (e.g. the 3D highway) alongside the default 2D highway - **Per-panel invert toggle** — flip individual panels between player and audience perspective independently - **Per-panel note detection** — each panel can independently detect notes from a specific audio input channel; pairs with the [Note Detect](https://github.com/got-feedback/feedBack-plugin-notedetect) plugin for multi-guitar setups -- **Pop a panel into its own window** — click **⇱ Pop** on any panel to open it in a new browser window; drag it to a second monitor and resize it freely. The popup is muted and paused (it doesn't even decode the audio) and slaved to the main window's audio time, so there's still only one sound source. Click **⇲ Dock** to send the panel back to its splitscreen slot; just closing the popup window instead removes that panel. +- **Name your panels** — each panel has an editable name label pinned to its **top-right corner**. The name persists across sessions, is **carried when you pop the panel out**, and is exposed to other plugins (e.g. [Camera Director](https://github.com/nimuart/cameradirector_feedback) targets each panel's 3D-highway camera by name) via the panel API below. +- **Pop a panel into its own window** — click **⇱ Pop** at a panel's **top-right, next to its name**, to open it in a new browser window; drag it to a second monitor and resize it freely. The popup is muted and paused (it doesn't even decode the audio) and slaved to the main window's audio time, so there's still only one sound source. Click **⇲ Dock** (same spot) to send the panel back to its splitscreen slot; just closing the popup window instead removes that panel. - **Split a popped window internally** — every popup gets its own bottom toolbar with a layout picker (Single / Top-Bottom / Left-Right / Quad). A popped window can mirror the same layouts as the main splitter, so you can run e.g. a quad on a second monitor with all four arrangements while the main window stays single-panel. - **Hide/show bottom controls bar** — click **▾ Bar** (next to Close) to collapse the global player controls and reclaim the vertical space; a floating **▴ Controls** pill restores them - **Hide/show per-panel mini bar** — each panel has a **▾ Bar** button pinned to its bottom-right corner to collapse that panel's controls independently; click **▴ Bar** to restore @@ -37,6 +38,10 @@ docker compose restart Split screen works with any song that has more than one arrangement. +### Naming panels + +Each panel shows an editable name (default `P1`, `P2`, …) at its **top-right corner**. Click it, type a name, and press Enter (or click away) to commit. Names persist across sessions and are carried into a popped-out window. Beyond being a label, the name is the **handle other plugins use to target a specific panel** — e.g. Camera Director lists panels by name so you can give each its own 3D-highway camera and steer popped-out panels from the main window. + ### Hiding controls for more screen space - **Bottom controls bar** — when splitscreen is active, a **▾ Bar** button appears next to the Close button. Click it to hide the player controls bar; panels expand to fill the freed space. A floating **▴ Controls** pill appears at the bottom-right of the player to bring the bar back. @@ -46,7 +51,7 @@ Both states are saved to `localStorage` and restored automatically the next time ### Popping a panel onto a second monitor -Each panel's mini bar has a **⇱ Pop** button. Clicking it opens that panel in its own browser window, which you can then drag to a second monitor and resize independently — handy for multi-monitor practice setups where you want the highway, lyrics, or jumping tab on a separate screen from the main player. +Each panel has a **⇱ Pop** button at its **top-right, next to the panel name** (it stays reachable even when the panel's bottom mini-bar is hidden). Clicking it opens that panel in its own browser window, which you can then drag to a second monitor and resize independently — handy for multi-monitor practice setups where you want the highway, lyrics, or jumping tab on a separate screen from the main player. The panel's **name** is carried into the popped window, so tools that target panels by name (e.g. Camera Director) keep working across the pop-out. While popped: @@ -229,6 +234,32 @@ if (typeof window.createMyVisualization === 'function') { - **Lyrics pane** — `createLyricsPane()` in [screen.js](screen.js). DOM-based renderer, single WebSocket, RAF loop for karaoke highlighting. - **Jumping Tab pane** — `window.createJumpingTabPane()` in the [Jumping Tab plugin](https://github.com/renanboni/slopsmith-plugin-jumpingtab). Canvas renderer with context-swapping to share draw functions across multiple pane instances. +### Per-panel identity & names (for panel-targeting plugins) + +Plugins that need to act on individual panels — apply a per-panel setting, mount panel-scoped chrome, or (like Camera Director) drive a per-panel camera — read the panel registry off `window.feedBackSplitscreen` (alias `window.slopsmithSplitscreen`; prefer `window.feedBackSplitscreen || window.slopsmithSplitscreen`): + +```js +const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; +if (ss && ss.isActive()) { + for (const p of ss.getPanels()) { + // p = { index, name, canvas, focused, poppedOut } + console.log(p.index, p.name, p.focused); + } +} +``` + +| Member | Returns / effect | +|--------|------------------| +| `isActive()` | `true` while split (or in a follower window that has built its panels) | +| `getPanels()` | `[{ index, name, canvas, focused, poppedOut }]` for the current window — including a **follower window's own panels** once it has split | +| `panelIndexFor(canvas)` | the panel index a highway `canvas` belongs to (or `null`) | +| `isCanvasFocused(canvas)` | whether that canvas's panel is the focused one | +| `panelName(i)` / `setPanelName(i, name)` | read / set a panel's name | +| `onFocusChange(fn)` / `offFocusChange(fn)` | subscribe to focus changes | +| `panelChromeFor(canvas)` / `settingsAnchorFor(canvas)` | DOM anchors for per-panel overlays / buttons | + +Panel add/remove/rename (and a follower re-splitting) fires **`splitscreen:panels-changed`** on the `window.feedBack` event bus — re-read `getPanels()` when you receive it. Names are per-window; a popped-out panel carries its name into its follower window, and that window's own `getPanels()` exposes any panels it splits into (so a panel-targeting plugin can address them too). The [Camera Director plugin](https://github.com/nimuart/cameradirector_feedback) is a reference consumer — it lists every window's panels by name and can steer popped-out panels from the main window. + ### Testing Checklist Before shipping, verify: diff --git a/screen.js b/screen.js index cd3cb81..b8fc08f 100644 --- a/screen.js +++ b/screen.js @@ -370,6 +370,7 @@ try { popupId: params.get('popupId') || '', filename: params.get('filename') || '', arrangement: parseInt(params.get('arrangement'), 10) || 0, + name: params.get('name') || '', mode: params.get('mode') || '2d', inverted: params.get('inverted') === '1', lefty: params.get('lefty') === '1', @@ -448,8 +449,31 @@ try { offFocusChange(fn) { focusListeners.delete(fn); }, + + // Panel enumeration for cross-plugin consumers (e.g. Camera Director's + // panel selector). `name` is user-editable via the per-panel bar and + // persists; changes fire `splitscreen:panels-changed` on window.feedBack. + getPanels() { + if (!active) return []; + return panels.map((p, i) => ({ + index: i, name: p.name || ('P' + (i + 1)), + canvas: p.canvas, focused: i === focusedPanelIdx, poppedOut: false, + })); + }, + panelName(i) { return (panels[i] && panels[i].name) || (i != null ? ('P' + (i + 1)) : ''); }, + setPanelName(i, name) { + if (!panels[i]) return; + const nm = String(name || '').trim().slice(0, 40) || ('P' + (i + 1)); + panels[i].name = nm; + if (panels[i].nameInput) panels[i].nameInput.value = nm; + savePanelPrefs(); _emitPanelsChanged(); + }, }; + // Alias under the canonical name (slopsmith → feedBack rename in flight). + // Consumers should read `window.feedBackSplitscreen || window.slopsmithSplitscreen`. + window.feedBackSplitscreen = window.slopsmithSplitscreen; + // 3D Highway palette IDs. Mirrors the PALETTES registry in the 3dhighway // plugin's screen.js — kept as a plain list here to avoid a runtime // dependency on the plugin being loaded. @@ -542,12 +566,31 @@ try { detectVerifierOffsetMs: p.detectVerifierOffsetMs || 0, barHidden: p.bar.style.display === 'none', mastery: p.hw.getMastery(), + name: p.name || '', }; } function savePanelPrefs() { localStorage.setItem(STORAGE_KEY, JSON.stringify(panels.map(panelToPrefs))); } + // Notify cross-plugin consumers (e.g. Camera Director's panel selector) that + // the panel set or a panel name changed, via the window.feedBack event bus. + function _emitPanelsChanged() { + try { if (window.feedBack && typeof window.feedBack.emit === 'function') window.feedBack.emit('splitscreen:panels-changed'); } catch (_) { /* ignore */ } + } + // Commit an edited panel name (from the bar input): sanitize, store on the + // panel, persist, and notify. Empty falls back to the positional default. + function _commitPanelName(panelDiv, raw) { + const i = panels.findIndex((p) => p.panelDiv === panelDiv); + if (i === -1) return; + const name = String(raw || '').trim().slice(0, 40) || `P${i + 1}`; + if (panels[i].name === name) return; + panels[i].name = name; + if (panels[i].nameInput && panels[i].nameInput.value !== name) panels[i].nameInput.value = name; + savePanelPrefs(); + _emitPanelsChanged(); + } + function loadPanelPrefs() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || null; @@ -902,11 +945,32 @@ try { // — e.g. a stale full-screen viz overlay — can bleed through the bar. 'background:#08080e;z-index:7;'; - // Panel label - const label = document.createElement('span'); - label.style.cssText = 'font-size:11px;color:#888;font-weight:bold;min-width:16px;'; - label.textContent = `P${index + 1}`; - bar.appendChild(label); + // Panel name + Pop/Dock — a cluster pinned to the panel's TOP-RIGHT (not + // in the bottom bar: the main player's auto-hiding transport + left icon + // rail overlap the panel's bottom, blocking the leftmost panels' bar, and + // Pop stays reachable even when the bottom mini-bar is hidden). The name + // doubles as the user-facing handle other plugins (e.g. Camera Director) + // show to target this panel. Persists in panel prefs; changes emit + // `splitscreen:panels-changed` on the window.feedBack bus. The Pop/Dock + // button is created further down and inserted to the LEFT of the name. + const nameWrap = document.createElement('div'); + nameWrap.style.cssText = + 'position:absolute;top:6px;right:6px;z-index:8;' + + 'display:flex;align-items:center;gap:6px;'; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.value = `P${index + 1}`; + nameInput.spellcheck = false; + nameInput.title = 'Rename this panel'; + nameInput.style.cssText = + 'width:96px;font-size:11px;color:#cbd5e1;font-weight:bold;text-align:right;' + + 'background:rgba(8,8,16,0.5);border:1px solid transparent;border-radius:4px;' + + 'padding:2px 6px;outline:none;'; + nameInput.addEventListener('focus', () => { nameInput.style.borderColor = '#4080e0'; nameInput.style.background = 'rgba(8,8,16,0.95)'; nameInput.select(); }); + nameInput.addEventListener('blur', () => { nameInput.style.borderColor = 'transparent'; nameInput.style.background = 'rgba(8,8,16,0.5)'; _commitPanelName(panelDiv, nameInput.value); }); + nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') nameInput.blur(); e.stopPropagation(); }); + nameWrap.appendChild(nameInput); + panelDiv.appendChild(nameWrap); // Arrangement selector const select = document.createElement('select'); @@ -1022,16 +1086,18 @@ try { masteryLabel.textContent = '—'; bar.appendChild(masteryLabel); - // Pop Out / Dock — visibility flips by mode (FOLLOWER => Dock; main => Pop Out). - // The actual click handlers are wired in initPanel() so they have access - // to the panel object via closure. We append at the end of the bar - // (no `margin-left:auto` because barToggleBtn lives absolute-positioned - // at bottom:0;right:0 and the auto-margin would collide with it). + // Pop Out / Dock — sits at the panel's TOP-RIGHT, just left of the name + // (label flips by mode: FOLLOWER => Dock; main => Pop Out). Living up here + // (rather than in the bottom mini-bar) keeps it reachable when the bar is + // hidden and clear of the main player's overlapping transport. The click + // handler is wired in initPanel() so it has the panel object via closure. const popOutBtn = document.createElement('button'); popOutBtn.style.cssText = - 'padding:2px 6px;border-radius:4px;font-size:10px;' + - 'border:1px solid #333;cursor:pointer;background:#1a1a2e;color:#9ca3af;' + - 'white-space:nowrap;'; + 'padding:2px 7px;border-radius:4px;font-size:11px;font-weight:bold;' + + 'border:1px solid transparent;cursor:pointer;background:rgba(8,8,16,0.5);color:#cbd5e1;' + + 'white-space:nowrap;outline:none;'; + popOutBtn.addEventListener('mouseenter', () => { popOutBtn.style.background = 'rgba(8,8,16,0.95)'; popOutBtn.style.borderColor = '#4080e0'; }); + popOutBtn.addEventListener('mouseleave', () => { popOutBtn.style.background = 'rgba(8,8,16,0.5)'; popOutBtn.style.borderColor = 'transparent'; }); if (FOLLOWER) { popOutBtn.textContent = '⇲ Dock'; popOutBtn.title = 'Return this panel to the main window'; @@ -1039,7 +1105,7 @@ try { popOutBtn.textContent = '⇱ Pop'; popOutBtn.title = 'Open this panel in a new window'; } - bar.appendChild(popOutBtn); + nameWrap.insertBefore(popOutBtn, nameInput); panelDiv.appendChild(bar); @@ -1096,7 +1162,7 @@ try { container.appendChild(panelDiv); return { - panelDiv, canvas, bar, barToggleBtn, select, arrName, + panelDiv, canvas, bar, barToggleBtn, select, arrName, nameInput, invertBtn, updateInvertStyle, leftyBtn, updateLeftyStyle, lyricsBtn, updateLyricsStyle, @@ -2381,6 +2447,11 @@ try { 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)); @@ -2629,7 +2700,11 @@ try { 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): @@ -3740,11 +3815,23 @@ try { // 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 broadcast channel for time / playstate / song-change // / main-closed. Re-assigning `onmessage` on each rebuild replaces the // prior handler (no listener stacking); each handler reads the live