Simplify LTC to its one working source — the built-in audio listener — and
make the admin UI reflect it.
Module:
- Remove the stage-display websocket source (stage-ws.js, stagePassword)
and the /v1/timecode/status API probe — both dead on ProPresenter 21.4.
- Remove the remote-reader ingest (POST /ltc, ingestToken, tools/
ltc-reader.js): the module owns LTC end to end now. The decoder selftest
moves to modules/propresenter-timers/ltc.selftest.js so the engine stays
covered.
- composeLtc collapses to the listener: running/stopped/nosignal, or a
clear note when disabled / no device / capture failed / starting.
- Manifest: ltcEnabled is now a `switch`; ltcDevice and ltcChannel carry
showWhen:"ltcEnabled"; ProPresenter and LTC fields carry `group` labels.
Shell (admin) — backward-compatible additions, coordinate with the shell lane:
- New `switch` field type (boolean shown as the enable-toggle control).
- `group`: consecutive fields render under a labeled subsection.
- `showWhen: "<key>"`: a field is shown only while that boolean/switch is
on (and actually collapses — .field[hidden] override, since .field's
display:flex outranks the [hidden] UA default).
- applyConfigPatch coerces `switch` as boolean. Documented in MODULE-GUIDE.
Verified: engine selftest (29 checks); listener decodes BlackHole loopback
(running 30fps, ageMs); device picker lists inputs; POST /ltc now 404s;
admin shows two labeled groups, the switch, and device/channel appearing
only when the switch is on (computed display none↔flex confirmed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The LTC card repainted only on server anchors (~4x/s), so the frame
counter jumped in ~7-frame steps. Now the client counts every frame
locally between anchors and re-syncs on each one — frame-accurate and
smooth, with no extra network traffic.
- Server anchors carry ageMs (how old the decoded timecode is) so clients
can pin the anchor to their own clock. ltc-listener reports it while
running; composeLtc passes it through (listener) or derives it (reader).
- Client does exact SMPTE frame<->timecode math (parseTc/tcToFrames/
framesToTc, incl. 29.97 drop-frame minute counting) and advances the
display in a requestAnimationFrame loop at the LTC's own rate.
- Robust when rAF is throttled (backgrounded tab): render() paints the
extrapolated frame at the 4x/s anchor rate too, so the display never
freezes — the rAF loop only refines it to every frame when visible.
- Anchor jitter can't tick the counter backwards (small regressions
clamped; genuine seeks/re-strikes pass); freshness cap stops freewheel
into phantom frames when anchors stop arriving.
Verified: SMPTE math against the encoder over 200k drop-frame frames plus
round-trips; the extrapolation pipeline simulated at 30/25/29.97DF showing
strict +1-per-frame advance and correct DF boundary crossing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopts the new contract: the manifest declares "proddash": ">=1.2.0"
(this module's admin device picker needs the optionsRoute select support
this branch adds), module version 1.2.0, and the shell version bumps
1.1.0 → 1.2.0 for that admin capability. The optionsRoute paragraph
merged into the guide's new schema section cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProdDash now has a version (package.json, 1.1.0) and modules declare the
shell versions they need ("proddash": ">=1.1.0" in the manifest). A module
whose requirement this shell doesn't meet is listed in the admin page but
never loaded, and cannot be installed from the catalog.
Modules come from two places: bundled with the checkout (modules/) and
installed from the admin page into the data directory (<data dir>/modules/).
Same id in both → the newer version runs; a downloaded copy the bundled one
has overtaken is removed. The admin page's new "Available modules" section
lists modules in the ProdDash repo that aren't installed here — one small
tar.gz of the branch is fetched (at most every few minutes, only while the
admin page is open or for the periodic update check) and read with a
dependency-free tar reader (ustar, GNU long names, pax headers; the pax
global comment carries the commit sha). Install downloads the module folder
into the data directory and mounts it immediately; a module card offers
"Update to vX" when the repo has a newer version. Uninstall removes an
installed module's files; a bundled module is hidden instead (its folder
belongs to the app) and can be restored from Available modules without a
download. Settings are kept across uninstall/reinstall.
Shell updates: the server compares the repo's package.json version and
commit sha with its own (git rev-parse when the app folder is a checkout),
shows a banner in the admin page, and "Update now" brings the app folder up
to date — git pull --ff-only for a checkout that is on the repo branch with
no local changes (file modes ignored), otherwise the archive is unpacked over
the folder (runtime state and .git untouched). The server then exits with
code 75 and starts its own replacement unless PRODDASH_LAUNCHER is set; the
replacement waits for the port to free up (EADDRINUSE retry). The page polls
until the new version answers. PRODDASH_REPO (owner/repo#branch),
PRODDASH_ARCHIVE_BASE and PRODDASH_NO_REMOTE_CHECK configure/disable it.
Docs: MODULE-GUIDE gets the rules every module follows (all settings in the
admin page, dependencies shipped inside the module, everything runs inside
the module's server entry — no helper programs, declare the ProdDash
requirement and bump the version), the proddash/version manifest fields and
the new installation story; README gets "Installing modules and updating";
CLAUDE.md summarises the rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
The menu bar's show/hide tab was centred on the screen, exactly where a
centred tile's own title-bar tab lands once both bars are hidden: two
identical tabs on top of each other, with the tile's unreachable. The tab
now sits at the left under the ProdDash brand and wears the accent colour,
so it never coincides with a tile tab and reads unmistakably as the
dashboard's. To keep it off a top-row tile's status dot and title it
straddles the bar's bottom edge while the bar is shown (mostly on the bar,
a short lip below) and becomes a slim tab at the very top edge when the bar
is hidden.
The Admin link is hidden in fullscreen, including when the menu bar is
brought back there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Start ProdDash.command now prefers ./runtime/bin/node (production machines
have no system Node) and kills whatever already holds :24500 before
starting — so relaunching from the GUI (which the LTC listener needs for
microphone permission) is one double-click, not a port-conflict hunt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LTC listener now lives inside the module: it spawns the native
ltc-capture helper (moved into the module folder, compiled on first use,
exact-name device matching) for the admin-selected input device, extracts
the configured channel, and decodes SMPTE LTC in-process (ltc.js — the
engine, shared with tools/ltc-reader.js, which remains the remote-machine
option posting to /ltc).
Admin config: an "LTC listener" switch, an audio-device select populated
live from the new GET /ltc/devices route, and a 1-based channel number.
The switch off means LTC is gone entirely: no LTC entry in the Add-tile
picker, no LTC row in the Timers menu or All-timers grid, no stage/API
probes, and POST /ltc answers 409. Source precedence with it on:
listener-with-data, fresh remote reader, listener's own no-signal verdict,
then the legacy API/stage chain.
Shell (admin page): `select` fields learn `optionsRoute` — options fetched
from a module route at render, saved value kept selectable when the route
or device is gone. Documented in MODULE-GUIDE; backward compatible.
Coordinate with the shell lane before its next admin.js change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tiles get a third resize handle: a pill on the bottom edge (hover-only,
between the two corner handles) that moves that edge. With nothing directly
below, it changes just the tile's height. When other tiles share the edge,
the drag trades rows with them so the column keeps its total height — the
vertical counterpart of the seam pill. Clamped to every tile's minimum
height; blocked by collisions like the corner handles.
Fullscreen auto-fit reworked so columns actually reach the bottom of the
screen. The old rule only stretched tiles whose bottom edge was within two
rows of the viewport bottom, which on a display taller than the browser
window the layout was designed in left a blank band below every column.
Now the bottom-most tile of each column stretches to the bottom regardless
of distance. Short tiles (h <= 2, the clock) are never stretched into a
ribbon: under another tile they drop to the bottom edge and the tile above
grows into the gap (stopping at anything else below it); alone in their
columns they stay put. The fit re-runs from a ResizeObserver on the grid
area, so the browser's animated fullscreen transition and the menubar
collapse both settle into a correct fit (replaces the resize/transitionend
listeners).
README documents the pill and the fullscreen behaviour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Real Dante capture produced a bit-error frame whose frames field read 35
yet passed the lax range check (f<=59, hours<=39), and max-frame-based
rate detection then reported 36 fps to the dashboard permanently. Now:
every BCD digit is validated and fields respect hard ceilings (f<=29,
h<=23); nominal fps locks only from two agreeing second-rollovers (the
frame number preceding f=0 with seconds+1), so one glitch can't fake it
and a genuine rate change re-locks within two seconds; the drop-frame
flag gets two-frame hysteresis. Selftest reproduces the forged-35 frame
plus PCM glitches and pins fps at 30 throughout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Production machines rarely have ffmpeg/sox, and the LTC reader only needs
"device N → s16le stdout". This ~200-line Swift CLI (compiles with the
Xcode CLT already on the target: swiftc -O -o tools/ltc-capture
tools/ltc-capture.swift) enumerates input devices (--list), pins an
AVAudioEngine tap to one picked by name substring (--device), and streams
interleaved s16le at the device's own rate/channel count, printing the
matching --sr/--channels reader flags to stderr.
Verified by a real loopback on BlackHole 64ch: synthesized LTC played into
the device, captured back (channel 2 of 64 @ 44.1 kHz), decoded to
running/stopped on live tiles with zero drift over 14 s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Connection settings, API keys, enabled flags and named layouts were written
to config/ inside the checkout, so a re-clone or reset from main started the
server with nothing configured. They now live in a data directory outside
the app folder:
Windows %APPDATA%\ProdDash
macOS ~/Library/Application Support/ProdDash
other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash
PRODDASH_DATA_DIR overrides the location (a relative path resolves against
the app folder, so "config" restores the old spot). The first start after
upgrading copies an existing config/modules.json and config/layouts/ into
the data directory, so nothing has to be re-entered. If the directory cannot
be created or written, the server warns and falls back to config/ rather
than refusing to start. A proddash.json in the data directory overrides the
checked-in defaults (port, adminPasscode).
The startup banner prints the directory in use, /api/admin/state exposes it,
and the admin page shows it under the Modules heading. Admin config and
layout writes that fail on disk now report the path instead of a generic
server error. README, MODULE-GUIDE and CLAUDE.md updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
LTC usually rides one input of a console bus or Dante/MADI interface, and
downmixing it into program audio (-ac 1) corrupts the bit transitions. New
flags --channels <n> (interleaved count of the stdin stream, no practical
upper bound) and --ch <n> (1-based pick) deinterleave in the reader, so
the capture command stays a plain passthrough. The extractor keeps frame
alignment across arbitrary chunk splits; selftest adds an LTC-on-5-of-8
case fed in odd-sized chunks. Mono remains the default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProPresenter 21.4 exposes no timecode through any API, so read the LTC
where it actually lives — the audio feed:
- tools/ltc-reader.js: zero-dependency SMPTE LTC decoder (biphase-mark,
adaptive bit clock, envelope-scaled comparator) fed s16le PCM on stdin
from ffmpeg/sox; POSTs running/stopped/nosignal + timecode + frame rate
(incl. 29.97 drop-frame) to the module. --selftest runs an encoder→
decoder suite (24/25/30 fps, DF minute counting, low level + noise + DC,
dropout recovery); --demo synthesizes live LTC through the real decoder
and ingest path with no audio hardware.
- Module: POST /ltc ingest route (optional bearer token, validated body,
6 s staleness with automatic fallback to the API probe / stage source),
richer LTC state (status, fps, df, reader source), health messages.
- Client: LTC card shows Running · <rate> / Stopped (held frame) /
No signal; works LTC-only with no ProPresenter host configured; the
reader-fed card no longer grays out when only ProPresenter is down.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against ProPresenter 21.4: the legacy stage-display protocol
(ptl 610 — the only version PP21 accepts; newer values get 'out of
date') omits stage objects it predates, so a timecode field labeled LTC
never appears in any layout's frame list, and no timecode messages were
observed while idle. Fallback: an unknown uid streaming timecode-shaped
text (a fourth :FF group; not any configured timer's uuid) is bound as
LTC after two advancing samples, and that binding survives layout
refreshes. pp-timers-mock gains --ltc-unlisted to exercise the path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProPresenter's HTTP API has no timecode route (verified: 21.4 returns
404), but its classic stage-display websocket still answers — so LTC now
comes from a stage layout field labeled "LTC": a zero-dependency RFC6455
client (stage-ws.js) authenticates (new stagePassword admin config),
resolves the LTC-labeled frame's uid from the layout list, and treats any
message carrying that uid as the timecode text. "Receiving" is a 2.5s
staleness heuristic; frozen or placeholder text reads as no-signal. The
HTTP probe stays authoritative when it ever answers (a 404 verdict is
retried once a minute), and the LTC card surfaces the reason when
timecode is unavailable (bad stage password, no LTC field). pp-timers-mock
grows a /stagedisplay websocket with --stage-pwd and --no-ltc-field to
exercise every path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Title bars (tiles and the main menubar, which gains the same notch tab)
now collapse and reopen with a height animation instead of popping. The
menubar's hidden state persists per browser. ProdCom timestamps move
inline to the right of the channel name, keeping their text style; the
clock button toggles just their visibility. New fullscreen button (left
of +): entering fullscreen auto-hides the menubar and auto-fits the
layout — occupied columns stretch proportionally across the width, tiles
whose bottom edge lands within two rows of the viewport bottom stretch
to end exactly at it, small tiles (h ≤ 2) alone in their columns are
exempt, and scrolling is disabled when nothing extends below the fold
(deeper layouts keep natural rows and scrolling). All fullscreen fitting
is visual-only and reverts on exit; editing affordances are parked while
in it. Corner resize indicators now appear only on tile hover, and a
second resize handle on the bottom-left corner resizes from that edge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
New configSchema type 'endpoint' declared in the module spec: anything
that points at a device or service on the network must be one endpoint
field, rendered identically for every module in /admin as a joined
Host/IP : Port control and stored/handed to the module server as
{ host, port }. Migrates all three connection modules — ProdCom (was a
full URL field), ProPresenter and Timers (were separate host + port
fields). Legacy stored configs (prodcomUrl / flat host+port) keep
working until the admin form is re-saved, at which point the new shape
replaces them. MODULE-GUIDE documents the type and the presentation
rule: no split host/port fields, and full URLs only for genuine web
addresses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Adopt main's new multi-tile contract: the module's server exports
tiles() (answered from the poller's held state, never a fresh upstream
request) listing All timers, one entry per discovered timer, and LTC
timecode. The client branches on moduleApi.variant — solo tiles render
exactly one card with no selection menu, and a solo timer that vanishes
from ProPresenter shows a labeled "Not in ProPresenter" card instead of
erroring (visible even in the compact layout). pp-timers-mock gains
--drop=<uuid,...> to simulate a timer deleted in ProPresenter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New propresenter-timers module: polls /v1/timers + /v1/timers/current
(shapes verified against openapi.propresenter.com), probes the
undocumented /v1/timecode/status for LTC and honestly reports
"not available on this ProPresenter" when absent. Per-tile timer/LTC
selection via a Timers ▾ header menu (never-seen timers default on),
responsive auto-sized cards with state-derived colors (the API exposes
no per-timer color), unmistakable overrun. Includes tools/pp-timers-mock.js
for developing without a real ProPresenter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A module may now present a list of tiles instead of a single picker
entry. A server module exports tiles({config, log}) — sync or async,
called on every picker load so the list can be live data (discovered
ProPresenter timers, admin-configured pages); a client-only module can
declare a static tiles array in module.json. The + picker groups the
entries under the module's name; adding one presets that entry's
instance settings, gives the tile the entry's name as its title, and
honors per-entry default/min sizes, all persisted with the layout.
moduleApi.variant tells the instance which entry it was added as. A
throwing or hung tiles() (2 s deadline) falls back to the classic single
entry so a dead upstream can never break the picker; modules without
tiles are byte-for-byte unaffected. Documented in docs/MODULE-GUIDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
The initial build is merged; SPEC.md is no longer the live authority.
New-module work should follow the module guide and its ground rules
(self-contained folder, no shell changes, server-side proxying, scoped
CSS-variable styling, resilience checklist, mocks for testing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Topbar: the add button is now just + and the layout button a 3-segment
grid icon (two squares + one tall rectangle). Modules no longer build
their own toolbars — moduleApi.header.addButton/addMenu put controls in
the tile's title bar next to the gear/close (menus are fixed-positioned
by the shell so a tile's overflow can't clip them, and everything a
module adds is removed automatically on unmount). Each title bar grows a
notch on its bottom edge that hides/shows the bar, ProdCom-Listener
style; the choice persists with the layout. Modules renamed: ProdCom and
ProPresenter. The ProdCom tile regains the original listener tools as
icon buttons in its title bar: timestamps toggle (clock), A−/A+ text
size, jump-to-latest, and a circle-with-cross clear button, plus the
Channels menu. MODULE-GUIDE documents the header API; README updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Tiles now sit with zero gap (their 1px borders are the only seam) and the
grid runs edge to edge. Wherever two tiles share a vertical edge, an
invisible strip sits on the seam; hovering reveals a small grab pill
(split-view style) and dragging it moves the shared edge, trading width
between the two tiles. The combined span is preserved, both tiles' min
sizes are honored, and a third tile sitting behind part of the seam
blocks the drag instead of being overlapped. Pills hide during tile
drags/resizes and rebuild for the new geometry on release; seam sizes
persist with the layout. Also fixes cellMetrics treating a 0px grid gap
as falsy and falling back to 10px.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 7. docs/MODULE-GUIDE.md documents the whole module contract
from an author's point of view — manifest reference, client factory +
moduleApi, server init/routes/health, scoping and resilience rules, a
copy-paste minimal module, and a testing section with the two mocks and a
resilience checklist. README rewritten with run/configure/use
instructions in the prodcom-listener style, plus double-click launchers
for macOS and Windows. PWA: manifest, icons, and a minimal network-first
service worker that never touches /api/*.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 6. Layouts CRUD as JSON under config/layouts/ — GET list,
GET/PUT by name (open to every dashboard), DELETE and rename gated by the
admin passcode. Layout menu grows 'Save as named layout…' and a
'Load from server' list; loading copies the layout into the browser's own
local state, never live-links viewers. Admin page lists, renames and
deletes named layouts. Verified with two isolated browser contexts
holding different layouts simultaneously.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 5. GET /api/admin/state, PUT /api/admin/modules/<id>/config and
/enabled persist to config/modules.json and re-init the module in place —
no restart. /api/events (SSE) tells open dashboards about admin changes;
the shell then calls onConfigChange(cfg) on instances that implement it
and cleanly remounts the rest, so tiles reconnect on their own. Admin
page renders forms from each configSchema (text/number/boolean/select/
password), shows module health from handle.health(), and never echoes
password values back to a browser. Optional shared passcode
(config/proddash.json adminPasscode or PRODDASH_PASSCODE) gates the admin
API behind a cookie; the dashboard API stays open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 4. propresenter-core copied in unchanged (single home for every
ProPresenter 7/20 quirk). Server part owns the one polling connection and
exposes GET /state plus GET /stream (SSE, change-detected, heartbeated);
health reported for the admin page. Client ports the wall display's
rendering: fill-the-box title fit that never breaks words, tidied titles,
slide meta + progress bar, NEXT list with hidden-row '+ N more' overflow,
playlist name and live chip, all scaled to the tile via a JS-set --unit.
Verified against pp-mock.js including the arrangement-repeat case (the
counter follows core's honest-live-index rule, never X > Y) and
kill-and-restart recovery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 3. Server part proxies /prodcom/* (SSE passthrough included) to
the admin-configured ProdCom URL, adding the Bearer key server-side so it
never reaches browsers; reports proxy health for the admin page. Client
ports the prodcom-listener UI into the module contract: history load,
live SSE with in-place updates of in-progress speech, channel colors,
channel visibility toggles + group presets (persisted per tile), hard
channel filter / text size / timestamps as instance settings, auto-scroll
with a jump-to-latest pill. tools/prodcom-mock.js simulates ProdCom
(word-by-word in-progress speech) for testing; verified against it,
including kill-and-restart recovery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 2. Server scans modules/*/module.json, serves module assets at
/modules/<id>/, mounts optional server routes (exact + trailing-/* prefix
patterns) under /api/modules/<id>/ with per-module config from
config/modules.json, and lists enabled manifests at GET /api/modules.
Shell dynamically imports each module's client.js and hands it the
moduleApi (config, instanceSettings, scoped fetch, auto-reconnecting sse,
setStatus). Clock module proves the contract end to end; password-typed
config fields never leave the server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 1 of SPEC.md. Zero-dependency Node server serving public/;
12-column tile grid with add (placeholder tile), drag to move, drag to
resize, remove; collision-free placement; layout autosaves to this
browser's localStorage and survives reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH