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
4 changes: 2 additions & 2 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Split Screen Plugin Constitution

The Split Screen plugin (id: `splitscreen`) renders 2–4 independent
The Split Screen plugin (id: `splitscreen`) renders 2–6 independent
highway panels side-by-side in the player, each showing a different
arrangement of the same song, all slaved to the shared `<audio>`
element. It supports popping a panel into a separate browser window
Expand Down Expand Up @@ -82,4 +82,4 @@ branches base off `origin/main`, NEVER `upstream/main`. PRs target
plugin-integration surface (Path 1 / Path 2 from README) must update
the README in the same PR.

**Version**: 1.4.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-05-09
**Version**: 1.4.1 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-10-01
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# feedBack Plugin: Split Screen

A plugin for [feedBack](https://github.com/got-feedback/feedBack) that shows 2–4 highway panels side-by-side during playback, each rendering a different arrangement of the same song. Practice lead and rhythm at once, watch bass against lead, or run a quad view of every arrangement a song has.
A plugin for [feedBack](https://github.com/got-feedback/feedBack) that shows **2–6 synchronized panels** during playback, each rendering a different arrangement of the same song. Practice lead and rhythm at once, watch bass against lead, or run a five- or six-panel view of every arrangement a song has.

## Features

- **Five layouts** — Top/Bottom (2P), Left/Right (2P), Tri 1+2 (3P, one on top + two on bottom), Tri 2+1 (3P, two on top + one on bottom), and Quad (4P, 2×2 grid)
- **Seven layouts, 2–6 panels** — Top/Bottom (2P), Left/Right (2P), Tri 1+2 (3P, one on top + two on bottom), Tri 2+1 (3P, two on top + one on bottom), Quad (4P, 2×2 grid), Five (5P, 2 over 3), and Six (6P, 3×2 grid)
- **Per-panel arrangement selector** — each panel has its own dropdown; swap what it renders mid-playback without restarting the song
- **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
- **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.
- **Split a popped window internally** — every popup gets its own bottom toolbar with a layout picker (Single / Top-Bottom / Left-Right / Quad). A pop-out window offers a **narrower layout set than the main window** — up to Quad, not the main window's Five and Six — 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
- **Smart defaults** — opens with lead → rhythm → bass auto-assigned across panels when those arrangements exist, wrapping to fill the rest
Expand Down Expand Up @@ -66,7 +66,7 @@ Just **closing** the popup window does *not* re-dock the panel — it stays remo

#### Splitting a popped window further

Every popup has a small toolbar pinned to its bottom edge with a **Layout** picker. The same layouts available in main are available here: **Single**, **Top/Bottom**, **Left/Right**, **Quad**. Switching layouts inside a popup keeps the slots you already configured and fills any new ones (lead → rhythm → bass) using the same smart-defaults the main toggle uses. Each popup has its own layout independent of main and any other popups, so you can run e.g. a quad of all four arrangements on a second monitor while keeping main on a single 3D highway.
Every popup has a small toolbar pinned to its bottom edge with a **Layout** picker offering **Single**, **Top/Bottom**, **Left/Right**, and **Quad** — a narrower set than the main window's seven, which run up to Six panels. Pop-out windows stop at Quad, so Five and Six are main-window only. Switching layouts inside a popup keeps the slots you already configured and fills any new ones (lead → rhythm → bass) using the same smart-defaults the main toggle uses. Each popup has its own layout independent of main and any other popups, so you can run e.g. a quad of all four arrangements on a second monitor while keeping main on a single 3D highway.

> Pop-out uses standard `window.open` and `BroadcastChannel`. The popup must be triggered by your click (a user gesture) so popup blockers should leave it alone — but if your browser does block it (or doesn't support `BroadcastChannel`), the panel stays put and you get a brief notice instead. If you close the main window while popups are open, each popup detects it and shows a "main window closed" notice so you know to close it (best-effort — if the browser doesn't deliver that last message the popup just freezes, same as before).

Expand All @@ -90,7 +90,7 @@ Requirements & caveats:

## Settings

Open **Settings → Split Screen** to pick the default layout (Top/Bottom, Left/Right, Tri 1+2, Tri 2+1, or Quad). The choice is stored in `localStorage` as `splitscreenLayout` and applies the next time you toggle split screen on.
Open **Settings → Split Screen** to pick the default layout (Top/Bottom, Left/Right, Tri 1+2, Tri 2+1, Quad, Five, or Six). The choice is stored in `localStorage` as `splitscreenLayout` and applies the next time you toggle split screen on. The same seven layouts sit in the layout picker in the player toolbar whenever split screen is active, whatever the default is.

## Note Detection

Expand Down Expand Up @@ -217,7 +217,7 @@ window.createMyVisualization = function ({ container }) {

| Rule | Why |
|------|-----|
| **No shared mutable state** | Split screen may create 2–4 instances simultaneously. Each needs its own canvas, WebSocket, RAF handle, and state. Refactor module-level variables into closures — everything lives inside the factory call, like `createLyricsPane()` does. |
| **No shared mutable state** | Split screen may create 2–6 instances simultaneously (one per panel). Each needs its own canvas, WebSocket, RAF handle, and state. Refactor module-level variables into closures — everything lives inside the factory call, like `createLyricsPane()` does. |
| **Decode the filename** | `currentFilename` always arrives percent-encoded (every caller — the grid, v3's `songs.js`, `player.start()` — encodes it before calling `playSong`). Call `decodeURIComponent(filename)` before building the WebSocket URL to avoid double-encoding slashes. |
| **Sync to `<audio>` directly** | Read `document.getElementById('audio').currentTime` in your RAF loop. The `setTime()` call from split screen's time sync loop is for highway instances only. |
| **Clean up completely in `destroy()`** | Cancel RAF, close WebSocket, remove any DOM nodes you added inside the container. Split screen removes the container div itself. |
Expand Down
4 changes: 2 additions & 2 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "splitscreen",
"name": "Split Screen",
"version": "1.14.21",
"version": "1.14.22",
"private": false,
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"capabilities": {
Expand All @@ -28,6 +28,6 @@
"script": "screen.js",
"tour": "tour.json",
"category": "creation",
"description": "Practice multiple arrangements at once — split into 2–4 synced panels, each with its own view, pop one out to another monitor, or share one to other devices on your network.",
"description": "Practice multiple arrangements at once — split into 2–6 synced panels, each with its own view, pop one out to another monitor, or share one to other devices on your network.",
"icon": "assets/thumb.svg"
}
2 changes: 1 addition & 1 deletion screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ try {

/* ======================================================================
* Split Screen Plugin
* Creates 2-4 independent highway panels, each showing a different
* Creates 2-6 independent highway panels, each showing a different
* arrangement from the same song. All panels sync to the shared
* <audio> element.
* ====================================================================== */
Expand Down
2 changes: 2 additions & 0 deletions settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<option value="tri-top">Tri — 1 Top / 2 Bottom (3P)</option>
<option value="tri-bottom">Tri — 2 Top / 1 Bottom (3P)</option>
<option value="quad">Quad (4P)</option>
<option value="five">Five — 2 Over 3 (5P)</option>
<option value="six">Six — 3×2 Grid (6P)</option>
</select>
</div>
<div class="flex items-center gap-3 mt-3">
Expand Down
104 changes: 104 additions & 0 deletions tests/screen.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ function makeDocumentStub(onAddEventListener = noop) {

const PLUGIN_PATH = path.join(__dirname, '..', 'screen.js');

function readRepoFile(name) {
return require('node:fs').readFileSync(path.join(__dirname, '..', name), 'utf8');
}

function loadPlugin() {
delete require.cache[require.resolve(PLUGIN_PATH)];
return require(PLUGIN_PATH);
Expand Down Expand Up @@ -470,6 +474,106 @@ test('_bestFitLayout picks the smallest layout with room, and caps at six', () =
assert.equal(_bestFitLayout(7), 'six'); // nothing bigger — caller must truncate
});

// ── Documented capacity vs LAYOUTS (splitscreen#71) ───────────────────────
// The README led with "2–4 panels" / "Five layouts" for releases after the
// grid layouts landed, and that stale summary is what dropped the four→six
// increase from the v1.14.20 notes. Deriving the advertised numbers from
// LAYOUTS means the next layout added without a doc update fails here.
const NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];

function panelRange(LAYOUTS) {
const counts = Object.values(LAYOUTS).map((l) => l.panels);
return { min: Math.min(...counts), max: Math.max(...counts) };
}

function sortedKeys(keys) {
return [...keys].sort();
}

// Reads `{ 'key': number }` entries out of a `const NAME = { ... }` literal.
// Throws rather than returning an empty match so a renamed or refactored
// declaration fails loudly instead of feeding vacuous assertions.
function readPanelCountMap(src, name) {
const literal = src.match(new RegExp(`const ${name} = \\{([\\s\\S]*?)\\n \\};`));
assert.ok(literal, `${name} literal not found in screen.js`);
const entries = [...literal[1].matchAll(/'([\w-]+)':\s*(\d+)/g)].map((m) => [m[1], Number(m[2])]);
assert.ok(entries.length > 0, `${name} literal parsed no entries`);
return new Map(entries);
}

// Reads the hand-written `{ value, label }` option list built after the element
// carrying `id`. Both pickers use the same shape, so the id is the only
// reliable way to tell the main window's list from the pop-out's.
function readPickerOptionValues(src, id) {
const anchor = src.indexOf(id);
assert.ok(anchor > 0, `${id} not found in screen.js`);
const start = src.indexOf('const options = [', anchor);
const end = src.indexOf('];', start);
assert.ok(start > anchor && end > start, `${id} option list not found in screen.js`);
const values = [...src.slice(start, end).matchAll(/value:\s*'([\w-]+)'/g)].map((m) => m[1]);
assert.ok(values.length > 0, `${id} option list parsed no entries`);
return values;
}

test('README advertises the panel range and layout count LAYOUTS actually provides', () => {
const { LAYOUTS } = freshPlugin();
const { min, max } = panelRange(LAYOUTS);
const readme = readRepoFile('README.md');
const lede = readme.split('\n').find((l) => l.startsWith('A plugin for'));
assert.ok(lede, 'README lede paragraph not found');
assert.match(lede, new RegExp(`${min}–${max}[^.]*panels`),
`README lede should advertise the ${min}–${max} panel range LAYOUTS supports`);
const count = NUMBER_WORDS[Object.keys(LAYOUTS).length];
assert.ok(count, `no number word for ${Object.keys(LAYOUTS).length} layouts — extend NUMBER_WORDS`);
assert.match(readme, new RegExp(`\\b${count} layouts\\b`, 'i'),
`README should name ${count} layouts, one per LAYOUTS key`);
});

test('the default-layout setting offers exactly the LAYOUTS keys', () => {
const { LAYOUTS } = freshPlugin();
const settings = readRepoFile('settings.html');
const select = settings.slice(settings.indexOf('id="splitscreen-default-layout"'));
const close = select.indexOf('</select>');
assert.ok(close > 0, 'default-layout <select> not found in settings.html');
const offered = [...select.slice(0, close).matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
// Order isn't load-bearing (a change writes the key back, not the index),
// so compare as a set — reordering LAYOUTS or the markup isn't drift.
assert.deepEqual(sortedKeys(offered), sortedKeys(Object.keys(LAYOUTS)),
'Settings → Split Screen default-layout picker must offer every LAYOUTS key');
});

test('the player toolbar layout picker offers exactly the LAYOUTS keys', () => {
const { LAYOUTS } = freshPlugin();
const offered = readPickerOptionValues(readRepoFile('screen.js'), 'splitscreen-layout-btn');
assert.deepEqual(sortedKeys(offered), sortedKeys(Object.keys(LAYOUTS)),
'the toolbar picker is a hand-written copy of LAYOUTS — both must be updated together');
});

test('pop-out layouts stay a strict subset of the main-window layouts', () => {
const { LAYOUTS } = freshPlugin();
const follower = readPanelCountMap(readRepoFile('screen.js'), 'FOLLOWER_LAYOUT_PANELS');
for (const key of follower.keys()) {
assert.ok(key === 'follower' || LAYOUTS[key], `${key} should be a main-window layout`);
}
// README advertises pop-outs as "up to Quad", with Five and Six main-window
// only, so this needs the exact set — a bare "max is smaller" check would
// still pass once 'five' lands here.
assert.deepEqual(sortedKeys(follower.keys()), ['follower', 'left-right', 'quad', 'top-bottom'],
'pop-out layouts should remain Single / Top-Bottom / Left-Right / Quad');
assert.match(readRepoFile('README.md'), /narrower layout set than the main window/,
'README should state that pop-out layouts are a narrower set than the main window\'s');
});

test('the pop-out layout picker offers exactly the pop-out layouts', () => {
// An option with no FOLLOWER_LAYOUT_PANELS entry is silently dead:
// rebuildFollowerLayout bails on the unknown key, so the popup's layout
// just never changes. Matching the map is what keeps the list honest.
const follower = readPanelCountMap(readRepoFile('screen.js'), 'FOLLOWER_LAYOUT_PANELS');
const offered = readPickerOptionValues(readRepoFile('screen.js'), 'follower-layout-select');
assert.deepEqual(sortedKeys(offered), sortedKeys(follower.keys()),
'every pop-out picker option needs a FOLLOWER_LAYOUT_PANELS entry to switch to');
});

test('deterministic frame coordination waits for a compatible panel without starting a no-op loop', () => {
const src = require('node:fs').readFileSync(PLUGIN_PATH, 'utf8');
assert.match(src, /let _deterministicFramesActive = false;/);
Expand Down
2 changes: 1 addition & 1 deletion tour.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"id": "layout-picker",
"selector": "#splitscreen-layout-btn",
"title": "Pick a layout",
"content": "Top/bottom, left/right, or quad (four panels). Switch any time — your per-panel preferences carry over.",
"content": "Seven layouts, from two panels up to six: top/bottom, left/right, the two tris, quad, five, and six. Switch any time — your per-panel preferences carry over.",
"shape": "spotlight",
"position": "top"
},
Expand Down
Loading