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
160 changes: 160 additions & 0 deletions .github/workflows/compliance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
name: compliance

# A targeted subset of the plugin-spec checks that the org's original-repo
# plugins run via their own custom-checks.yml. This repo's ci.yml calls the
# shared reusable-ci.yml, which runs the test suites and nothing else — so
# before this file, fork-derived plugins got tests but no spec enforcement
# while the originals got enforcement but (in one case) no tests.
#
# Deliberately not the full 16-job set. These three are the ones that map to
# defects a cross-repo audit actually found in these repos: versions and
# changelogs going stale while functional source shipped, and screen.js
# re-executing on plugin reload without a guard. Jobs self-skip when they
# don't apply (no CHANGELOG.md, no screen.js).
#
# The right long-term home for these is got-feedback/.github's
# reusable-ci.yml, so every repo picks them up from one reference. Delete
# this file when that lands.

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read

env:
PLUGIN_DIR: "."

jobs:
version-bumped-on-change:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: plugin.json version must bump when functional source changes
run: |
set -eu
BASE="${{ github.event.pull_request.base.sha }}"
if [ -z "$BASE" ]; then
BASE="${{ github.event.before }}"
fi
if [ -z "$BASE" ] || ! git cat-file -e "$BASE" 2>/dev/null; then
echo "No usable base commit to diff against — skipping"
exit 0
fi
# Diff against the MERGE BASE, not the base tip. On pull_request,
# github.event.pull_request.base.sha is main's live tip, so a
# two-dot diff also reports commits that landed on main after this
# branch forked — failing the PR for a bump it does not owe.
BASE=$(git merge-base "$BASE" HEAD || echo "$BASE")
DIR="$PLUGIN_DIR"
CHANGED=$(git diff --name-only "$BASE" HEAD -- "$DIR" || true)
FUNCTIONAL=$(echo "$CHANGED" | grep -E '\.(py|js|html|css)$' | grep -vE '(^|/)tests?/' | grep -v 'plugin\.json$' || true)
if [ -z "$FUNCTIONAL" ]; then
echo "No functional source changed — skipping"
exit 0
fi
MANIFEST_CHANGED=$(git diff --name-only "$BASE" HEAD -- "$DIR/plugin.json" || true)
if [ -z "$MANIFEST_CHANGED" ]; then
echo "::error file=$DIR/plugin.json::functional source changed ($FUNCTIONAL) but plugin.json was not — bump the version"
exit 1
fi
OLD_VERSION=$(git show "$BASE:$DIR/plugin.json" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('version',''))" || echo "")
NEW_VERSION=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json')).get('version',''))")
if [ "$OLD_VERSION" = "$NEW_VERSION" ]; then
echo "::error file=$DIR/plugin.json::plugin.json changed but version is still $NEW_VERSION — bump it"
exit 1
fi
echo "OK: version bumped $OLD_VERSION -> $NEW_VERSION"

idempotent-top-level-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Top-level listeners/timers must be inside a reload guard
run: |
python3 - <<'PY'
import os, re, sys

plugin_dir = os.environ["PLUGIN_DIR"]
js_path = os.path.join(plugin_dir, "screen.js")
if not os.path.isfile(js_path):
print("No screen.js — skipping")
raise SystemExit(0)

src = open(js_path, encoding="utf-8").read()
has_hotpath = bool(re.search(r'(window\.addEventListener|document\.addEventListener|setInterval)\s*\(', src))
# Guard naming varies across the org (__xInstalled, __xHooksInstalled,
# __feedBackDynamicDifficulty, __ddCardBadgeRegistered, ...) — no
# single suffix covers them, so just require *some* window-level __
# flag rather than pattern-matching a specific naming style.
#
# Both access forms count. section_map, the org's reference guard,
# hoists the name into a const and uses window[HOOK_KEY]; matching
# only the dotted form flags it as unguarded when it is not.
dotted = re.search(r'window\.__\w+', src)
bracket_literal = re.search(r'window\s*\[\s*[\'"]__\w+[\'"]\s*\]', src)
bracket_const = (
re.search(r'window\s*\[\s*[A-Za-z_$][\w$]*\s*\]', src)
and re.search(r'[\'"]__\w+[\'"]', src)
)
has_guard = bool(dotted or bracket_literal or bracket_const)

# Known limitation: this proves a guard EXISTS somewhere in the
# file, not that every hotpath sits inside one. A new unguarded
# listener added alongside an existing guard still passes. It is a
# cross-repo floor, not proof of idempotency.
if has_hotpath and not has_guard:
print(f"::error file={js_path}::found addEventListener/setInterval but no window.__*Installed-style reload guard — screen.js can re-execute on plugin reload")
sys.exit(1)
print("OK: no unguarded top-level listeners detected (or none present)")
PY

changelog-updated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: CHANGELOG.md Unreleased section must be updated for functional changes
run: |
set -eu
if [ ! -f CHANGELOG.md ]; then
echo "No CHANGELOG.md in this repo — skipping"
exit 0
fi
BASE="${{ github.event.pull_request.base.sha }}"
if [ -z "$BASE" ]; then
BASE="${{ github.event.before }}"
fi
if [ -z "$BASE" ] || ! git cat-file -e "$BASE" 2>/dev/null; then
echo "No usable base commit to diff against — skipping"
exit 0
fi
# Diff against the MERGE BASE, not the base tip. On pull_request,
# github.event.pull_request.base.sha is main's live tip, so a
# two-dot diff also reports commits that landed on main after this
# branch forked — failing the PR for a bump it does not owe.
BASE=$(git merge-base "$BASE" HEAD || echo "$BASE")
FUNCTIONAL=$(git diff --name-only "$BASE" HEAD -- "$PLUGIN_DIR" | grep -E '\.(py|js|html|css)$' | grep -vE '(^|/)tests?/' || true)
if [ -z "$FUNCTIONAL" ]; then
echo "No functional source changed — skipping"
exit 0
fi
# Touching the file is not enough — editing an already-released
# section while [Unreleased] stays put is the exact slip this is
# meant to catch. Compare the [Unreleased] section itself.
unreleased() {
git show "$1:CHANGELOG.md" 2>/dev/null \
| awk '/^## \[Unreleased\]/{f=1;next} /^## \[/{f=0} f'
}
if [ "$(unreleased "$BASE")" = "$(unreleased HEAD)" ]; then
echo "::error file=CHANGELOG.md::functional source changed but CHANGELOG.md's [Unreleased] section did not (a release cut that empties it also counts as a change)"
exit 1
fi
echo "OK: CHANGELOG.md [Unreleased] changed"

32 changes: 23 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ screen.js
| `wrap` | element\|null | The `#splitscreen-wrap` div, or null when inactive |
| `currentFilename` | string\|null | The filename passed to the last `playSong` call |
| `arrangements` | array | Arrangement list from the last `song_info` WebSocket message |
| `vizPlugins` | array | `{id, name, …}` entries from `/api/plugins` where `type==='visualization'`. Populated once on page load via `fetchVizPlugins()`. Factory availability (`slopsmithViz_<id>`) is checked lazily in `populateSelect()`, not at fetch time. |
| `vizPlugins` | array | `{id, name, …}` entries from `/api/plugins` where `type==='visualization'`. Populated once on page load via `fetchVizPlugins()`. Factory availability (`feedBackViz_<id>`, legacy `slopsmithViz_<id>`) is checked lazily in `populateSelect()`, not at fetch time. |
| `syncInterval` | id\|null | The `setInterval` handle for the time sync loop |
| `layoutBtn` | element\|null | The layout `<select>` injected into `#player-controls` |
| `hideBtn` | element\|null | The `▾ Bar` button injected into `#player-controls` |
Expand Down Expand Up @@ -182,7 +182,11 @@ Each panel is always in exactly one of these modes. Flags are mutually exclusive

### Viz renderer (`vizMode = pluginId string`)
- Highway NOT stopped — it stays alive with its WebSocket and rAF loop
- `panel.hw.setRenderer(window['slopsmithViz_' + pluginId]())` installs the renderer
- `panel.hw.setRenderer(vizFactory(pluginId)())` installs the renderer. The factory global
was renamed `slopsmithViz_<id>` -> `feedBackViz_<id>` in the feedBack rename, so
`vizFactory(id)` walks `VIZ_FACTORY_PREFIXES = ['feedBackViz_', 'slopsmithViz_']` in that
order and returns the first hit — current and legacy viz plugins both resolve.
`hasVizFactory(id)` is the boolean form used for capability checks.
- `canvas` stays visible (renderer draws to it)
- Tab button hidden. A **"3D ⚙"** button (`vizSettingsBtn`) is shown if the viz plugin has per-panel controls (see "Per-panel viz controls" below); it opens `vizPopover` with those controls scoped to this panel. Other viz config still lives in the plugin's global settings UI.
- To exit: `recreatePanelHighway(panel)` discards the viz highway and installs a fresh 2D highway; `_hideVizControls(panel)` hides the button/popover
Expand All @@ -198,10 +202,10 @@ 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` ("3D ⚙") 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)` returns `null` for any plugin other than `highway_3d` (v1 — `_vizPanelGet`/`_vizPanelSet` are hard-wired to highway_3d's storage scheme + `window.h3dBgSet*` setters); for `highway_3d` it returns `window.slopsmithViz_highway_3d.panelControls` if exposed, else the built-in `VIZ_PANEL_CONTROLS.highway_3d` (`palette`, `cameraSmoothing`, `cameraLockLow`, `cameraLockZoom`). The viz-plugin-published list wins, so the plugin can keep the *list of controls* current without splitscreen edits — generalizing to other plugins later means extending the descriptor with per-plugin storage/setter info (or read/write fns) and dropping the gate. Each descriptor entry: `{ key, label, type:'toggle'|'range'|'select', default, min?, max?, step?, options? }` where `options` for `select` is `[{id,label}]`; for `range`, `min`/`max` default to `0`/`1` and `step` to `0.05` when omitted (`_ctlRange`). A plugin-published **empty array** is a valid override — it opts out of per-panel controls (`_showVizControls` hides the button on an empty list).
- **Descriptor lookup** — `getPanelControlsFor(pluginId)` returns `null` for any plugin other than `highway_3d` (v1 — `_vizPanelGet`/`_vizPanelSet` are hard-wired to highway_3d's storage scheme + `window.h3dBgSet*` setters); for `highway_3d` it returns `vizFactory('highway_3d').panelControls` if exposed, else the built-in `VIZ_PANEL_CONTROLS.highway_3d` (`palette`, `cameraSmoothing`, `cameraLockLow`, `cameraLockZoom`). The viz-plugin-published list wins, so the plugin can keep the *list of controls* current without splitscreen edits — generalizing to other plugins later means extending the descriptor with per-plugin storage/setter info (or read/write fns) and dropping the gate. Each descriptor entry: `{ key, label, type:'toggle'|'range'|'select', default, min?, max?, step?, options? }` where `options` for `select` is `[{id,label}]`; for `range`, `min`/`max` default to `0`/`1` and `step` to `0.05` when omitted (`_ctlRange`). A plugin-published **empty array** is a valid override — it opts out of per-panel controls (`_showVizControls` hides the button on an empty list).
- **Storage** — per-panel values are written to the viz plugin's own per-panel keys, **not** `splitscreenPanelPrefs`. For `highway_3d`: `localStorage['h3d_bg_panel<N>_<key>']` (read by the plugin's `_bgReadSetting`, falling back to the global `h3d_bg_<key>`). `_vizPanelGet` / `_vizPanelSet` implement this; `_vizPanelSet` also re-fires `window.h3dBgSet<Key>(<currentGlobal>)` so the plugin's change event runs (instant rebuild for settings like `palette`; the 3D renderer also re-reads everything per frame, so even without the re-fire the panel key takes effect next frame). On reload, `enterVizMode` → `_showVizControls` → `buildVizPopover` re-reads the keys, so the popover reflects the saved per-panel state. Stale `h3d_bg_panel<N>_*` keys from a panel that later stopped running 3D are inert (the plugin only reads `panel<N>` keys for a live panel N) — they're left in place, same as the original palette behavior.
- **Lifecycle** — `_showVizControls(panel, pluginId)` (builds the popover + shows the button) is called at the end of `enterVizMode` and the in-place viz-switch branch of `panel.select.onchange`. `_hideVizControls(panel)` (hides + empties) is called from `exitVizMode`, `enterLyricsMode`, `enterJumpingTabMode`. `togglePanelBar` closes the popover when hiding the bar (it's anchored to the bar height). A document-level capture `pointerdown` listener (`_closeAllVizPopovers`) closes any open popover on a click outside `.ss-viz-popover` / `[data-ss-viz-btn]`. The `vizSettingsBtn` click handler **rebuilds the popover from current localStorage every time it opens** — `_closeAllVizPopovers` / the outside-click handler only hide (don't empty), so the rebuild-on-open is the single point that guarantees the controls reflect any `h3d_bg_*` changes (e.g. via the plugin's own settings UI) made while the popover was closed.
- **Note for new viz plugins** that want per-panel controls: expose `window.slopsmithViz_<id>.panelControls = [...]` and use the `*_panel<N>_*` localStorage convention the plugin already reads (or, if it uses a different scheme, the descriptor would need to carry `read`/`write` fns — not implemented in v1; only `highway_3d` is wired).
- **Note for new viz plugins** that want per-panel controls: expose `window.feedBackViz_<id>.panelControls = [...]` and use the `*_panel<N>_*` localStorage convention the plugin already reads (or, if it uses a different scheme, the descriptor would need to carry `read`/`write` fns — not implemented in v1; only `highway_3d` is wired).

## `sizeCanvases()` — call it whenever layout space changes

Expand Down Expand Up @@ -323,7 +327,7 @@ The plugin capability-checks all external factories at runtime and gracefully di
| Factory | Checked via | Used in |
|---|---|---|
| `window.createJumpingTabPane` | `typeof === 'function'` | `populateSelect()`, `enterJumpingTabMode()` |
| `window['slopsmithViz_' + id]` | resolved via `fetchVizPlugins()` | `populateSelect()`, `enterVizMode()` — auto-discovered for any `type=visualization` plugin |
| `window['feedBackViz_' + id]` (legacy `slopsmithViz_` fallback) | resolved via `fetchVizPlugins()` | `populateSelect()`, `enterVizMode()` — auto-discovered for any `type=visualization` plugin |
| `window.createTabView` | `typeof === 'function'` | `initPanel()` (wires tabBtn) |
| `window.createNoteDetector` | `typeof === 'function'` | `initPanel()` (wires detectBtn/channelBtn) |

Expand Down Expand Up @@ -355,7 +359,17 @@ Follow the lyrics/jumping-tab pattern:

## Git and PR conventions

- All work goes on feature branches off `main` in this repo (`carochacs/feedBack-plugin-splitscreen`)
- PRs target `carochacs/feedBack-plugin-splitscreen`
- Use `gh pr create --repo carochacs/feedBack-plugin-splitscreen --base main --head carochacs:<branch>` from inside the plugin directory
- Always branch from `origin/main` — there is no separate upstream remote
- **`got-feedBack/feedBack-plugin-splitscreen` is the canonical plugin repo.** This
checkout (`get-flashbacks/feedBack-plugin-splitscreen`) is a fork of it. Keep changes
portable: anything a user or the upstream project would read — the README's install
instructions, links to feedBack or sibling plugins — points at `got-feedBack`, not at
this fork. Redirecting those to the fork is what stops the plugin being useful to the
repo it came from.
- Day-to-day work goes on feature branches off `main` in this fork, and PRs target this
fork: `gh pr create --repo get-flashbacks/feedBack-plugin-splitscreen --base main --head <branch>`
from inside the plugin directory.
- A fix that isn't fork-specific is worth opening upstream against
`got-feedBack/feedBack-plugin-splitscreen` too — same idea as core's
"upstream PRs retire debt" rule.
- Always branch from `origin/main`; there is no `upstream` remote configured here, so add
one explicitly if you're preparing an upstream PR.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A plugin for [feedBack](https://github.com/got-feedback/feedBack) that shows 2

```bash
cd /path/to/feedBack/plugins
git clone https://github.com/carochacs/feedBack-plugin-splitscreen.git splitscreen
git clone https://github.com/got-feedBack/feedBack-plugin-splitscreen.git splitscreen
docker compose restart
```

Expand Down
3 changes: 2 additions & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"id": "splitscreen",
"name": "Split Screen",
"version": "1.14.1",
"version": "1.14.2",
"private": false,
"standards": ["plugin-runtime-idempotent.v1"],
"settings": {
"html": "settings.html",
"category": "graphics"
Expand Down
Loading
Loading