Live portal: auto-arrange panels as an even grid on join/leave, Arrange button

Moves existing panels (video keeps playing) into cols=ceil(sqrt(n)) row-major and equalizes group sizes.
Verified headless for 3-6 streams.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 13:25:05 -06:00
parent 68bb0e66d5
commit 3432be691f
2 changed files with 27 additions and 7 deletions

View File

@@ -31,6 +31,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Streamer | One-time: dock → Timecode band → "Add band + clock source" (OBS) or leave the Go Live checkbox on — gives editors frame-accurate timecode | OBS dock / Go Live | live | | Streamer | One-time: dock → Timecode band → "Add band + clock source" (OBS) or leave the Go Live checkbox on — gives editors frame-accurate timecode | OBS dock / Go Live | live |
| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://live.…/dock/` | Discord login in the dock; dock mints a 12 h token per Go Live — **no passwords, verified live** | | Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://live.…/dock/` | Discord login in the dock; dock mints a 12 h token per Go Live — **no passwords, verified live** |
| Gamer | Self-serve pairing (paste Syncthing Device ID → folder + share created); then nothing, clips save where they always did | `https://clips.…/` + Syncthing | Discord (any server member) | | Gamer | Self-serve pairing (paste Syncthing Device ID → folder + share created); then nothing, clips save where they always did | `https://clips.…/` + Syncthing | Discord (any server member) |
| Viewer | Live portal lays streams out as an even grid automatically when one joins or leaves ("Arrange" button redoes it after manual moves); audio follows the focused window | `https://live.…/` | Discord |
| Everyone | Landing page with role-aware links and instructions | `https://media.…/` | Discord | | Everyone | Landing page with role-aware links and instructions | `https://media.…/` | Discord |
| Editor | Multicam: drop clips on a timeline, sync by the embedded timecode track (frame-accurate when the band was on), cross-check with voice audio; files are already at game resolution (band removed by the sweep) | Resolve / Premiere | — | | Editor | Multicam: drop clips on a timeline, sync by the embedded timecode track (frame-accurate when the band was on), cross-check with voice audio; files are already at game resolution (band removed by the sweep) | Resolve / Premiere | — |
| Editor | Browse by streamer/title, scrub in browser, download originals (zip/tar for folders), upload finals | copyparty `https://files.…/` | Discord (Authentik); rights by group `editors` / `media-admins` | | Editor | Browse by streamer/title, scrub in browser, download originals (zip/tar for folders), upload finals | copyparty `https://files.…/` | Discord (Authentik); rights by group `editors` / `media-admins` |

View File

@@ -8,6 +8,25 @@ import GoLive from './GoLive.jsx'
const components = { player: Player } const components = { player: Player }
const POLL_MS = 3000 const POLL_MS = 3000
// Lay every panel out as a grid (cols = ceil(sqrt(n)), row-major in stream-start order) and equalize sizes.
// Moves the existing panels (dockview keeps the React component and its <video> alive), so viewers do not
// reconnect. Called when a stream joins or leaves, and from the "Arrange" button.
export function arrange(api) {
const panels = [...api.panels].sort((a, b) => ((a.params?.since || '').localeCompare(b.params?.since || '')) || a.id.localeCompare(b.id))
const n = panels.length; if (n < 2) return
const cols = Math.ceil(Math.sqrt(n))
for (let i = 1; i < n; i++) {
const r = Math.floor(i / cols), c = i % cols
const ref = r === 0 ? panels[i - 1] : panels[(r - 1) * cols + c] // right of the previous in row 0, below the one above otherwise
panels[i].api.moveTo({ group: ref.group, position: r === 0 ? 'right' : 'bottom', skipSetActive: true })
}
const W = api.width || 0, H = api.height || 0; if (!W || !H) return
panels.forEach((p, i) => {
const c = i % cols, rowsInCol = Math.ceil((n - c) / cols)
p.group.api.setSize({ width: Math.floor(W / cols), height: Math.floor(H / rowsInCol) })
})
}
export default function App() { export default function App() {
const apiRef = useRef(null) const apiRef = useRef(null)
const [streams, setStreams] = useState([]) const [streams, setStreams] = useState([])
@@ -25,19 +44,18 @@ export default function App() {
const api = apiRef.current; if (!api) return // (in golive mode the dock is unmounted: no viewer connections) const api = apiRef.current; if (!api) return // (in golive mode the dock is unmounted: no viewer connections)
const have = new Set(api.panels.map(p => p.id)) const have = new Set(api.panels.map(p => p.id))
const want = new Set(list.map(s => s.id)) const want = new Set(list.map(s => s.id))
let changed = false
for (const s of list) { for (const s of list) {
if (!have.has(s.id)) { if (!have.has(s.id)) {
const last = api.panels[api.panels.length - 1] api.addPanel({ id: s.id, component: 'player', title: `${s.user} · ${s.title}`, params: s,
api.addPanel({ position: api.panels.length ? { direction: 'right' } : undefined }) // relative to the whole grid; arrange() places it
id: s.id, component: 'player', title: `${s.user} · ${s.title}`, changed = true
params: s,
position: last ? { referencePanel: last.id, direction: api.panels.length % 2 ? 'below' : 'right' } : undefined,
})
} else { } else {
api.getPanel(s.id)?.api.updateParameters(s) api.getPanel(s.id)?.api.updateParameters(s)
} }
} }
for (const p of [...api.panels]) if (!want.has(p.id)) { api.removePanel(p); audio.forget(p.id) } for (const p of [...api.panels]) if (!want.has(p.id)) { api.removePanel(p); audio.forget(p.id); changed = true }
if (changed) arrange(api)
} catch (e) { if (alive) setErr(e.message) } } catch (e) { if (alive) setErr(e.message) }
} }
tick(); const t = setInterval(tick, POLL_MS) tick(); const t = setInterval(tick, POLL_MS)
@@ -56,6 +74,7 @@ export default function App() {
<span className="count">{streams.length ? `${streams.length} live` : 'nobody is live'}{err ? ` · ${err}` : ''}</span> <span className="count">{streams.length ? `${streams.length} live` : 'nobody is live'}{err ? ` · ${err}` : ''}</span>
<span className="spacer" /> <span className="spacer" />
<span className="count">audio follows the focused window</span> <span className="count">audio follows the focused window</span>
<button onClick={() => apiRef.current && arrange(apiRef.current)} title="lay the windows out as an even grid">Arrange</button>
<button onClick={() => audio.muteAll()} title="mute every window until you click one">Mute all</button> <button onClick={() => audio.muteAll()} title="mute every window until you click one">Mute all</button>
<button onClick={() => audio.followFocus()} title="clear pinned audio; only the focused window plays sound">Follow focus</button> <button onClick={() => audio.followFocus()} title="clear pinned audio; only the focused window plays sound">Follow focus</button>
<button className="golive-btn" onClick={() => { audio.muteAll(); apiRef.current = null; setMode('golive') }} title="stop watching and broadcast a window from this browser">Go Live</button> <button className="golive-btn" onClick={() => { audio.muteAll(); apiRef.current = null; setMode('golive') }} title="stop watching and broadcast a window from this browser">Go Live</button>