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

@@ -8,6 +8,25 @@ import GoLive from './GoLive.jsx'
const components = { player: Player }
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() {
const apiRef = useRef(null)
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 have = new Set(api.panels.map(p => p.id))
const want = new Set(list.map(s => s.id))
let changed = false
for (const s of list) {
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,
position: last ? { referencePanel: last.id, direction: api.panels.length % 2 ? 'below' : 'right' } : undefined,
})
api.addPanel({ id: s.id, component: 'player', title: `${s.user} · ${s.title}`, params: s,
position: api.panels.length ? { direction: 'right' } : undefined }) // relative to the whole grid; arrange() places it
changed = true
} else {
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) }
}
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="spacer" />
<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.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>