Live portal: custom layouts are left alone — new streams chime and wait in a draggable tray; Arrange re-grids and returns to auto

layout store (auto/custom + pending), marked custom on manual move (onDidMovePanel outside arrange), sash drag,
stream/group rename. Tray chips: HTML5 drag accepted via onUnhandledDragOver/onDidDrop (position -> direction),
click = new column. WebAudio two-note chime. Gateway page instructions updated. Verified headless (custom vs auto).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 14:36:01 -06:00
parent b2e7e9a4bb
commit 645f447015
8 changed files with 115 additions and 13 deletions

View File

@@ -31,7 +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 | 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) |
| 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; double-click a tab to rename a stream (kept in that browser), double-click the slot at the left of a tab bar to name the group (session) | `https://live.…/` | Discord |
| 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; double-click a tab to rename a stream (kept in that browser), double-click the slot at the left of a tab bar to name the group (session); once the viewer has moved/resized/renamed anything the layout is left alone: new streams chime and wait as draggable chips in a tray, "Arrange" re-grids and returns to auto | `https://live.…/` | 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 | 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

@@ -42,7 +42,8 @@
</details>
<details><summary>Live page: windows, groups, names</summary>
<ul>
<li><b>Layout.</b> Windows arrange themselves into an even grid whenever someone goes live or stops. Drag a tab to move a window, drag the bars between windows to resize, drop a tab onto another window to stack them as tabs. <b>Arrange</b> (top bar) snaps everything back to the grid.</li>
<li><b>Layout.</b> Until you touch it, windows arrange themselves into an even grid whenever someone goes live or stops. Drag a tab to move a window, drag the bars between windows to resize, drop a tab onto another window to stack them as tabs.</li>
<li><b>Your layout stays yours.</b> The moment you move, resize or rename anything, the page stops rearranging. Streams that end just close. New streams <b>do not</b> barge in: a chime plays and a blinking chip appears in a thin tray under the top bar. Drag the chip onto any window edge, onto a tab bar to stack it, or to the page edge; or click it to add it as a new column on the right. <b>Arrange</b> (top bar) places everything in an even grid and switches automatic layout back on.</li>
<li><b>Audio.</b> Sound follows the window you click. The speaker button pins a window's audio on even when you click elsewhere; <b>Mute all</b> silences everything until you click a window; <b>Follow focus</b> clears pins.</li>
<li><b>Rename a stream.</b> Double-click its tab, type a name, Enter. Escape cancels; an empty name goes back to <code>user · title</code>. Names stay in your browser and come back the next time that streamer uses the same title.</li>
<li><b>Name a group.</b> Every tab bar starts with a <code>+ name</code> slot. Double-click it, type, Enter. Group names last until you reload the page.</li>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { DockviewReact } from 'dockview-react'
import { DockviewReact, positionToDirection } from 'dockview-react'
import Player from './Player.jsx'
import { fetchStreams } from './api.js'
import { audio } from './useAudio.js'
@@ -8,6 +8,8 @@ import Tab from './Tab.jsx'
import GroupLabel from './GroupLabel.jsx'
import GroupActions from './GroupActions.jsx'
import { streamLabel } from './names.js'
import { layout, ding } from './layout.js'
import Tray, { STREAM_MIME } from './Tray.jsx'
const components = { player: Player }
const tabComponents = { player: Tab }
@@ -16,7 +18,16 @@ 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.
function addStream(api, s, position) {
api.addPanel({ id: s.id, component: 'player', tabComponent: 'player', title: streamLabel(s.id, s), params: s, position })
}
let arranging = false
export function arrange(api) {
arranging = true
try { arrangeInner(api) } finally { arranging = false }
}
function arrangeInner(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
if (api.hasMaximizedGroup?.()) api.exitMaximizedGroup?.() // a maximised window would hide the new grid
@@ -33,12 +44,14 @@ export function arrange(api) {
})
}
window.__vg = { layout } // debug/test hook
export default function App() {
const apiRef = useRef(null)
const [streams, setStreams] = useState([])
const [err, setErr] = useState(null)
const [mode, setMode] = useState('watch') // watch | golive
const focused = audio.use(s => s.focused)
const custom = layout.use(s => s.custom)
// poll MediaMTX; open/close panels to match
useEffect(() => {
@@ -51,17 +64,21 @@ export default function App() {
const have = new Set(api.panels.map(p => p.id))
const want = new Set(list.map(s => s.id))
let changed = false
const custom = layout.get().custom
for (const s of list) {
if (!have.has(s.id)) {
api.addPanel({ id: s.id, component: 'player', tabComponent: 'player', title: streamLabel(s.id, s), params: s,
position: api.panels.length ? { direction: 'right' } : undefined }) // relative to the whole grid; arrange() places it
changed = true
if (custom && api.panels.length) { // hands off a custom layout: park it in the tray and ding
if (!layout.get().pending.some(p => p.id === s.id)) { layout.addPending(s); ding() }
} else {
addStream(api, s, api.panels.length ? { direction: 'right' } : undefined); 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); changed = true }
if (changed) arrange(api)
layout.keepPending(want)
if (changed && !layout.get().custom) arrange(api)
} catch (e) { if (alive) setErr(e.message) }
}
tick(); const t = setInterval(tick, POLL_MS)
@@ -69,9 +86,28 @@ export default function App() {
}, [])
const onReady = useCallback(e => {
apiRef.current = e.api
e.api.onDidActivePanelChange(p => audio.focus(p?.id ?? null))
const api = e.api; apiRef.current = api
api.onDidActivePanelChange(p => audio.focus(p?.id ?? null))
api.onDidMovePanel(() => { if (!arranging) layout.markCustom() }) // a drag by the viewer, not our arrange()
api.onUnhandledDragOver(ev => { // chips from the tray: show dockview's drop targets
const dt = ev.nativeEvent?.dataTransfer; if (dt && Array.from(dt.types || []).includes(STREAM_MIME)) ev.accept()
})
api.onDidDrop(ev => {
const id = ev.nativeEvent?.dataTransfer?.getData(STREAM_MIME); if (!id) return
const dir = positionToDirection(ev.position)
place(id, ev.group ? { referenceGroup: ev.group, direction: dir } : { direction: dir === 'within' ? 'right' : dir })
})
}, [])
// add a parked stream where the viewer dropped/clicked it (custom layout stays custom)
const place = (id, position) => {
const api = apiRef.current; const s = layout.get().pending.find(p => p.id === id); if (!api || !s) return
addStream(api, s, position); layout.dropPending(id)
}
const arrangeAll = () => { // Arrange: place everything, re-grid, back to auto
const api = apiRef.current; if (!api) return
for (const s of layout.get().pending) addStream(api, s, { direction: 'right' })
layout.reset(); arrange(api)
}
return (
<div className="app">
@@ -80,7 +116,8 @@ 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>
<span className="count">{custom ? 'layout: yours (new streams wait in the tray)' : 'layout: auto grid'}</span>
<button onClick={arrangeAll} title="lay every window out as an even grid and go back to automatic layout">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>
@@ -92,7 +129,9 @@ export default function App() {
{streams.length === 0 && !err && (
<div className="empty"><h2>Nobody is live right now</h2><span>This page updates itself when a stream starts.</span></div>
)}
<div className="dock" style={{ display: streams.length ? 'block' : 'none' }}>
<Tray onPlace={place} />
<div className="dock" style={{ display: streams.length ? 'block' : 'none' }}
onPointerDownCapture={e => { if (e.target?.closest?.('.dv-sash')) layout.markCustom() }}>
<DockviewReact className="dockview-theme-vanguard" components={components} tabComponents={tabComponents}
leftHeaderActionsComponent={GroupLabel} rightHeaderActionsComponent={GroupActions} onReady={onReady} />
</div>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { names } from './names.js'
import { layout } from './layout.js'
// Left end of every tab bar: an editable group name (double-click). Session-only.
export default function GroupLabel({ group }) {
@@ -10,7 +11,7 @@ export default function GroupLabel({ group }) {
const ref = useRef(null)
useEffect(() => { if (editing) { ref.current?.focus(); ref.current?.select() } }, [editing])
const start = e => { e.stopPropagation(); setDraft(name || ''); setEditing(true) }
const commit = () => { if (!editing) return; names.renameGroup(id, draft.trim()); setEditing(false) }
const commit = () => { if (!editing) return; names.renameGroup(id, draft.trim()); layout.markCustom(); setEditing(false) }
return (
<div className="grouplabel" title="double-click to name this group" onDoubleClick={start} onPointerDown={e => e.stopPropagation()}>
{editing

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { names } from './names.js'
import { layout } from './layout.js'
// Tab for a stream panel: double-click to rename (viewer-side label, kept in this browser). Empty = back to user · title.
export default function Tab({ api, params }) {
@@ -11,7 +12,7 @@ export default function Tab({ api, params }) {
const ref = useRef(null)
useEffect(() => { if (editing) { ref.current?.focus(); ref.current?.select() } }, [editing])
const start = e => { e.stopPropagation(); setDraft(custom || ''); setEditing(true) }
const commit = () => { if (!editing) return; const v = draft.trim(); names.rename(id, v); api.setTitle(v || base); setEditing(false) }
const commit = () => { if (!editing) return; const v = draft.trim(); names.rename(id, v); api.setTitle(v || base); layout.markCustom(); setEditing(false) }
return (
<div className="vtab" title={`${base}\ndouble-click to rename`} onDoubleClick={start}>
{editing

23
live/src/Tray.jsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react'
import { layout } from './layout.js'
import { streamLabel } from './names.js'
export const STREAM_MIME = 'application/x-vanguard-stream'
// Zero-height when empty. New streams in a custom layout wait here: drag a chip into the dock (any edge, any window,
// or onto a tab bar to stack), or click it to add it as a new column on the right.
export default function Tray({ onPlace }) {
const pending = layout.use(s => s.pending)
if (!pending.length) return null
return (
<div className="tray" title="new streams — drag into the layout, or click to add on the right">
{pending.map(s => (
<div key={s.id} className="chip" draggable
onDragStart={e => { e.dataTransfer.setData(STREAM_MIME, s.id); e.dataTransfer.setData('text/plain', s.id); e.dataTransfer.effectAllowed = 'move' }}
onClick={() => onPlace(s.id, { direction: 'right' })}>
<span className="blink">●</span> {streamLabel(s.id, s)}
</div>
))}
</div>
)
}

30
live/src/layout.js Normal file
View File

@@ -0,0 +1,30 @@
import { create } from './tinyStore.js'
// Layout mode. "auto" = untouched grid: streams joining/leaving re-grid everything. The first manual move, resize
// or rename makes it "custom": the grid is left alone, leavers just close, joiners wait in the tray (with a ding)
// until dragged in. "Arrange" re-grids everything and goes back to auto.
export const layout = create({
custom: false,
pending: [], // streams live but not placed yet (custom mode)
markCustom() { return this.custom ? null : { custom: true } },
reset() { return { custom: false, pending: [] } },
addPending(s) { return this.pending.some(p => p.id === s.id) ? null : { pending: [...this.pending, s] } },
dropPending(id) { return this.pending.some(p => p.id === id) ? { pending: this.pending.filter(p => p.id !== id) } : null },
keepPending(ids) { const pending = this.pending.filter(p => ids.has(p.id)); return pending.length === this.pending.length ? null : { pending } },
})
// short two-note chime (WebAudio, no asset). Browsers need one prior click on the page to allow sound.
let ctx
export function ding() {
try {
ctx = ctx || new (window.AudioContext || window.webkitAudioContext)()
if (ctx.state === 'suspended') ctx.resume().catch(() => {})
const t0 = ctx.currentTime
for (const [i, f] of [[0, 880], [0.16, 1320]]) {
const o = ctx.createOscillator(), g = ctx.createGain()
o.type = 'sine'; o.frequency.value = f
g.gain.setValueAtTime(0, t0 + i); g.gain.linearRampToValueAtTime(0.25, t0 + i + 0.02); g.gain.exponentialRampToValueAtTime(0.001, t0 + i + 0.35)
o.connect(g).connect(ctx.destination); o.start(t0 + i); o.stop(t0 + i + 0.4)
}
} catch {}
}

View File

@@ -74,3 +74,10 @@ html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg)
.groupactions { display:flex; align-items:center; height:100%; padding:0 6px; }
.gbtn { background:transparent; color:var(--mut); border:0; cursor:pointer; font-size:14px; padding:0 6px; line-height:1; }
.gbtn:hover { color:var(--fg); }
/* tray: new streams waiting to be placed (custom layouts) */
.tray { display:flex; gap:8px; align-items:center; padding:4px 10px; background:#1d1a10; border-bottom:1px solid #3a3320; font-size:12px; }
.chip { display:flex; align-items:center; gap:6px; padding:3px 10px; border:1px solid #6b5a1e; border-radius:14px; background:#262012; color:var(--fg); cursor:grab; user-select:none; }
.chip:active { cursor:grabbing; }
.blink { color:#f1c40f; animation: vgblink 1s steps(2, start) infinite; }
@keyframes vgblink { to { visibility:hidden; } }