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
102 changes: 68 additions & 34 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,58 @@ Four things worth not re-learning:
selection change, which can happen mid-sweep, so a held node reference goes stale where a
cache entry does not. Re-opening shows the previous still immediately while a fresh sweep
replaces it, rather than flashing back to placeholders.
- **The tile is a fixed 128x72, not the row width.** A first pass used `width: 100%` with
`aspect-ratio`, which measured 292x164 tiles and 218px rows in the single list — 1166px of
content in a 640px panel, so a four-input picker scrolled. At a fixed size the name fits
*beside* the tile in the single list (~322px rows) and *below* it in a dual column
(~149px). Two rules, each matching its width.
- **Both open paths trigger it.** Hover (`mouseenter` on the trigger, which already existed
for the cursor) and touch (`toggleDropdown`). The panel's visibility is pure CSS, so that
listener is the only JS signal that a hover-open happened.
- **The tile is a fixed 128x72, not the strip width.** A first pass used `width: 100%` with
`aspect-ratio`, which measured 292x164 tiles. The name sits under the tile, and the strip
scrolls sideways when the inputs don't fit.
- **Both open paths go through `openDropdown()`.** Hover (`mouseenter` on the trigger) and
touch (`toggleDropdown`). That is where the sweep and the volume poll start.

### The dropdown is laid over the wall (dropdown 2b)

There is no panel. Opening it (`openDropdown`) fills `#wall-pickers` with one picker per
half — the current input, a strip of 128x72 tiles, and that half's volume — plus a capsule
at the top (Dual/Single, Output volume, Settings). There is no close button.
`renderDropdownInputLists()` kept its name so every existing caller (device change, input
switch by click or key, rename, enable/disable, layout) still refreshes it.

| View | Multi-view | Pickers | Tap sets | Key chips |
|---|---|---|---|---|
| dual | on | Left half, Right half | that half | none (1-4 set both) |
| dual | off | Both halves | both | 1-4 |
| single | on | Whole wall | left | 1-4 |
| single | off | Whole wall | both | 1-4 |

`pickerPlan()` is that table in code. Things worth not re-learning:

- **It closes when the pointer goes below the pickers** (`closeDropdownIfBelow`): past
the lowest picker row plus 48px. The lowest row is the volume slider, not the
thumbnails, or reaching for the slider would close it. A tap there closes it on touch.
It also closes with Esc, or after 30s with no pointer, touch or key activity
(`DROPDOWN_IDLE_MS`), so it can never sit over an unattended wall.
- **`multiView` (default true) is new.** Off means the halves always carry the same input:
`setMultiView(false)` brings the right half in line, and `openInitialStreams` enforces it
at startup.
- **Volume belongs to a side, not an input.** A "Both halves" picker sets both sides.
- **The input-name toast is suppressed while it is open.** The pickers already name what is
on each half, and the toast would only be hidden underneath them.

### Settings is a side-nav modal

Four panes: Inputs, Layout, Remote keyboard, Art-Net lighting. The shortcut table and the
separate No-Signal Detection section are gone: the legend has the shortcuts, and capture
moved into each input's No-signal panel ("Capture from left/right half", for whichever half
the device is on). Pure status logic (nav dots, status lines, the Key column,
`remoteKeyUrl`) is in `src/renderer/settings-status.js`.

- **Remote-keyboard presses go through main (`remote-key-send`)**, like Art-Net and for the
same CORS reason: a renderer `fetch` from `file://` with `X-API-Key` needs a preflight the
device does not answer.
- **Shortcuts ignore every form control**, not just `<input>`: with a `<select>` focused, Q
used to quit. Escape still gets through.
- **Esc does one thing per press**: with any panel open it closes panels and stops. Only
with nothing open does it unfreeze and leave fullscreen.
- **The visual language is system fonts only.** The handoff's TT Interphases is licensed for
internal SBP use and this repo and its installers are public.

### Keyboard shortcuts live in one list (#258)

Expand All @@ -134,15 +178,15 @@ Four consumers read from it, and none of them keeps its own copy:
| Consumer | How |
|---|---|
| the keydown handler | `SHORTCUTS_BY_KEY.get(event.key.toLowerCase())`, then `SHORTCUT_ACTIONS[id]` |
| the dropdown | `renderShortcutHints()` labels Dual/Single; the **single-view** input rows get `inputKeyFor(index)` |
| the Settings table | `renderShortcutHints()` fills `#shortcuts-table`, which ships empty |
| the legend (dropup) | `renderShortcutLegend()` fills `#legend-grid`, which also ships empty |
| the dropdown | `renderShortcutHints()` labels Dual/Single; tiles in pickers where a tap sets the whole wall get `inputKeyFor(index)` |
| the legend (dropup) | `renderShortcutLegend()` fills `#legend-grid`, which ships empty |
| `README.md` and `docs/USER_GUIDE.md` | still hand-written, but a test asserts every chip appears in both |

The legend is the mirror of the dropdown, on the bottom edge: same hover-to-reveal, same
slide, same `touch-open` class. It shows the **same rows** as the Settings table rather than a
shortened "important ones" set -- that would be a fourth hand-maintained list, which is what
this whole arrangement exists to remove. A test asserts the two carry identical chips.
shortened "important ones" set -- that would be another hand-maintained list, which is what
this whole arrangement exists to remove. Since the Settings table was dropped in the
dropdown 2b redesign, the legend is the only rendered copy.

Two things about it that measuring caught, both worth not repeating:

Expand All @@ -156,8 +200,7 @@ Two things about it that measuring caught, both worth not repeating:
rows mean anything. Uneven row heights are the cheaper cost.

Adding a key means adding one entry and one action. The entry alone gets you a
row in the table and a hint in the dropdown with a key that does nothing, and a
test fails for exactly that.
row in the legend with a key that does nothing, and a test fails for exactly that.

**Why this is worth the indirection.** There were four lists before, and three had
drifted. The Settings table was missing `Q`, `V`, `+`/`-` and `F11`; README was
Expand All @@ -169,33 +212,24 @@ Two things about the UI side worth not re-learning:
- The chips are sized off this UI's 12px floor, not shrunk until they stopped
competing. A first pass at 10px / opacity 0.55 read fine on a laptop and was
invisible on the wall -- 6000x1200 in a lit room. A test pins the floor.
- The dropdown rows put the device name in a `<span>` with `min-width: 0`. Without
- The dropdown tiles put the device name in a `<span>` with `min-width: 0`. Without
it the flex default of `min-width: auto` holds a long capture-card label at full
width and pushes the chip out of the row instead of ellipsising.

Past the fourth input row `inputKeyFor()` returns null and no chip is drawn. The
wall can have more capture devices than there are number keys, and labelling a
fifth row `5` would promise a binding that does not exist.

**The dual columns carry no chip, and that is about correctness, not space.**
**Per-half pickers carry no chip, and that is about correctness, not space.**
`1`-`4` call `selectInput()` with the default `side='both'` and set BOTH feeds;
clicking a row in the Left column calls `selectInputForSide(id, 'left')` and sets
one. A chip on a per-side row documents a key that does something different from
the control beside it. In single view one feed is shown, so setting both and
setting that one are the same thing to the operator, and the chip is honest.

It was reported as a fit bug in dual view, and it was that as well. Two things had
to be fixed:

- `.column-layout` needed `minmax(0, 1fr)`, not `1fr`. A bare `1fr` is
`minmax(auto, 1fr)` and the auto minimum is the item's **min-content** size, so a
`white-space: nowrap` name pinned the tracks open: they computed to 339.758px
each inside a 358px panel and spilled ~320px out of the dropdown. Same trap as
flex `min-width: auto`, one level up — and the flex one was already fixed in this
file, which is how the grid one got missed.
- Name truncation is scoped to `.single-input-option .input-option-name`, the only
list with a chip. A ~173px column has no room to both truncate and stay
readable, so dual-column names wrap as they did before the chips existed.
a tap in the Left half picker calls `selectInputForSide(id, 'left')` and sets one.
A chip there would document a key that does something different from the control
beside it. Where a tap sets the whole wall (single view, or dual without
Multi-view) the chip is honest, so it is shown.

The grid trap from the old dual columns still applies to the pickers' columns:
`#wall-pickers` uses `minmax(0, 1fr)`, never a bare `1fr`, because `1fr` is
`minmax(auto, 1fr)` and a `nowrap` name's min-content would pin the track open.

### Black-bar cropping and the 3840x768 hint

Expand Down
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ A lightweight video input viewer — **OBS without the complexity**. View and ma

![Input Viewer in dual view, with the input dropdown open](assets/screenshot-dual.png)

*Dual view with the dropdown open: per-side input selection, per-input and
system volume, and the centre divider between the two feeds.*
*Dual view with the input controls open: a strip of inputs and a volume slider
over each half, and the capsule at the top for the view, output volume and
Settings. Shown with eight `--mock` inputs.*

## Download

Expand Down Expand Up @@ -60,25 +61,27 @@ no-signal delay, and `+` / `-` step through the set (wrapping at both ends).
Stepping restarts the rotation countdown, so a manual pick is not replaced
moments later by the automatic rotation.

Hover over the top edge to reveal the settings dropdown panel.
Hover over the top edge (or tap the tab there) to bring up the input controls,
which are laid over the wall itself: one strip of inputs per half, with a capsule
at the top for the view, the output volume and Settings.

## Configuration

### Settings Panel

Click the ⚙ gear icon to open the settings panel:
Click the ⚙ gear icon to open the settings panel. A side menu has four sections:

- **Toggle inputs** on/off
- **Set default input** (shown at startup)
- **Rename inputs** for easy identification
- **Adjust center gap** between feeds
- **Adjust border width** on sides
- **Inputs** — Multi-view, and per input: on/off, name, the startup input, and its
no-signal references (captured from here)
- **Layout** — center gap and side borders, with a drawing of the wall to scale
- **Remote keyboard** and **Art-Net lighting** — each with a status line that says
whether it is working
- Changes are saved automatically

### settings.json

Settings are stored in the app's user data directory — the Settings panel shows
the exact path. Inputs are keyed by capture device id, since index order is not
Settings are stored in the app's user data directory (`settings.json` in
Electron's `userData` folder). Inputs are keyed by capture device id, since index order is not
stable across reboots:

```json
Expand Down
Binary file modified assets/screenshot-dual.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 46 additions & 18 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,19 @@ The app auto-updates when new versions are available.

### Selecting Inputs

1. Hover at the top of the screen to reveal the dropdown menu
2. Select which video input to show on each side (Dual view) or single input (Single view)
1. Hover at the top of the screen (or tap the tab there) to bring up the input controls
2. They appear **over the wall itself**: each half shows what it is playing and a strip of
inputs to choose from. Tap an input to put it on that half. The controls stay open, so
you can change both halves in one go
3. Move the pointer down past the controls (or press `Esc`) when you are done. They also close by themselves
after 30 seconds without anything being touched, so they never cover an unattended wall

In **Single** view there is one strip, for the whole wall, and its inputs carry the number
keys `1`-`4` because a tap does the same as the key.

**Multi-view** (Settings > Inputs, on by default) is what lets each half show a different
input. With it off, dual view always shows one input on both halves and the controls have a
single strip: one tap and it is on the whole wall.

### View Modes

Expand Down Expand Up @@ -85,41 +96,58 @@ bindings, so it always matches what the keys actually do.

## Settings

Open Settings via the dropdown menu (gear icon).
Open Settings with the gear in the capsule at the top of the input controls. It has four
sections in a side menu: **Inputs**, **Layout**, **Remote keyboard** and **Art-Net
lighting**. The menu shows what needs attention: a count of inputs without a no-signal
reference, and On/Off for the two integrations (orange when one is on but not working or
not filled in). Everything saves as you change it; there is no Apply button.

Each input in the dropdown shows a **snapshot** of what it is currently sending, taken
Each input in the controls shows a **snapshot** of what it is currently sending, taken
when the dropdown opens. These are stills, not live previews — they are as recent as the
moment you opened the panel. An input that is not currently on screen is sampled briefly to
take its picture; one that cannot be reached, or that has nothing plugged into it, keeps an
empty tile.

### Inputs

- **Enable/Disable** inputs using the toggle
- **Rename** inputs for easier identification
- **Set Default** input to load at startup
- **Multi-view** - Whether each half of the wall can show a different input (see above)
- **Key** - The number key that selects the input. Only enabled inputs are numbered, and
only the first four have a key
- **On** - Disabled inputs are hidden from the controls and the number keys. One that is on
the wall when you switch it off stays there until that half is switched
- **Name** - Leave empty to use the name the capture card reports
- **Startup** - The input shown when the app starts. Click it again to clear it
- **No-signal** - How many no-signal references the input has; click to see them

### Layout

- **Center Gap** - Space between dual view panels
- **Side Borders** - Black borders on the left/right edges
A drawing of the wall shows the halves, the center gap and the side borders to scale.

- **Center gap** - Space between the two halves (dual view only)
- **Side borders** - Black borders on the left and right edges

### No-Signal Detection

Capture what your capture card shows when nothing is connected. This allows the app to detect "no signal" and show the overlay.

1. Disconnect the input from your capture card
2. Click "Capture Left" or "Capture Right"
3. The app will remember this pattern
1. Disconnect the source from the capture card, so it shows its no-signal screen
2. Put that input on the wall
3. In Settings > Inputs, click its **No-signal** badge, then **Capture from left half** (or
right half, whichever it is on)
4. The app remembers the picture. If the capture fails it says why

References belong to the capture card, not to a half of the wall, so they keep working when
the card moves to the other side.

## Volume Controls

In the dropdown menu:
In the input controls:

- **Input sliders** - Control audio from each capture card
- **Output slider** - Control system volume
- **Volume** under each strip - The audio of that half of the wall. It belongs to the half,
not to the input, so it stays put when you switch inputs
- **Output** in the capsule at the top - System volume

The output slider syncs with your system volume every 2 seconds.
The output slider follows the system volume while the controls are open.

## Remote Keyboard

Expand Down Expand Up @@ -168,8 +196,8 @@ The app listens for multiple key types to support different clickers:

For touch screen setups:

- **Tap** the dropdown trigger area to open/close the menu
- **Tap outside** the menu to close it
- **Tap** the tab at the top edge to bring up the input controls
- **Tap** anywhere below the controls to put them away

## Screensavers

Expand Down
36 changes: 36 additions & 0 deletions input_viewer_electron/src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ const defaultSettings = {
layoutGap: 2,
inputs: {}, // { deviceId: { name: string, enabled: boolean } }

// Multi-view: each half of the wall can show a different input. Off means dual
// view always shows one input on both halves, and the dropdown has one row of
// inputs that sets both. On by default: that is how the wall has always worked.
multiView: true,

// Weather screensaver (issue #101). OFF by default, deliberately: this is the
// only feature that talks to a third party unprompted, so an install that is
// not supposed to reach the internet stays that way until someone opts in.
Expand Down Expand Up @@ -485,6 +490,37 @@ ipcMain.handle('artnet-send', async (event, request) => {
}
})

// Remote keyboard presses, for the same reason as artnet-send above: in production
// the renderer is `file://`, so a renderer fetch() with an X-API-Key header is a
// cross-origin request that needs a CORS preflight, and the presenter-PC device
// answers no OPTIONS. From main there is no origin and no preflight.
//
// Returns { ok, status } or { ok: false, error }; never throws across IPC.
const REMOTE_KEY_TIMEOUT_MS = 2000

ipcMain.handle('remote-key-send', async (event, request) => {
const { url, apiKey } = request || {}
let parsed
try {
parsed = new URL(String(url))
} catch {
return { ok: false, error: 'invalid url' }
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: `refusing protocol ${parsed.protocol}` }
}
try {
const res = await fetch(parsed.toString(), {
method: 'GET',
headers: { 'X-API-Key': String(apiKey ?? '') },
signal: AbortSignal.timeout(REMOTE_KEY_TIMEOUT_MS)
})
return { ok: res.ok, status: res.status }
} catch (err) {
return { ok: false, error: err && err.message ? err.message : String(err) }
}
})

ipcMain.handle('toggle-fullscreen', () => {
if (mainWindow) {
mainWindow.setFullScreen(!mainWindow.isFullScreen())
Expand Down
3 changes: 3 additions & 0 deletions input_viewer_electron/src/preload/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// LAN-mutating capability out of the renderer.
artnetSend: (request) => ipcRenderer.invoke('artnet-send', request),

// Remote keyboard presses, through main for the same CORS reason.
remoteKeySend: (request) => ipcRenderer.invoke('remote-key-send', request),

// Updater events
onUpdaterProgress: (callback) => {
ipcRenderer.on('updater-progress', (event, percent) => callback(percent))
Expand Down
Loading
Loading