Live portal: rename streams (tab double-click, persisted per browser) and name tab groups (header slot, session)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 14:12:21 -06:00
parent 9bdf9f1947
commit d5b2ba502b
7 changed files with 90 additions and 4 deletions

View File

@@ -4,8 +4,12 @@ import Player from './Player.jsx'
import { fetchStreams } from './api.js'
import { audio } from './useAudio.js'
import GoLive from './GoLive.jsx'
import Tab from './Tab.jsx'
import GroupLabel from './GroupLabel.jsx'
import { streamLabel } from './names.js'
const components = { player: Player }
const tabComponents = { player: Tab }
const POLL_MS = 3000
// Lay every panel out as a grid (cols = ceil(sqrt(n)), row-major in stream-start order) and equalize sizes.
@@ -47,7 +51,7 @@ export default function App() {
let changed = false
for (const s of list) {
if (!have.has(s.id)) {
api.addPanel({ id: s.id, component: 'player', title: `${s.user} · ${s.title}`, params: s,
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
} else {
@@ -87,7 +91,8 @@ export default function App() {
<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' }}>
<DockviewReact className="dockview-theme-vanguard" components={components} onReady={onReady} />
<DockviewReact className="dockview-theme-vanguard" components={components} tabComponents={tabComponents}
leftHeaderActionsComponent={GroupLabel} onReady={onReady} />
</div>
</>)}
</div>

22
live/src/GroupLabel.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React, { useEffect, useRef, useState } from 'react'
import { names } from './names.js'
// Left end of every tab bar: an editable group name (double-click). Session-only.
export default function GroupLabel({ group }) {
const id = group.id
const name = names.use(s => s.groups[id])
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
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) }
return (
<div className="grouplabel" title="double-click to name this group" onDoubleClick={start} onPointerDown={e => e.stopPropagation()}>
{editing
? <input ref={ref} value={draft} placeholder="group name" onChange={e => setDraft(e.target.value)} onBlur={commit}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(false) }} />
: <span className={name ? 'gname' : 'hint'}>{name || '+ name'}</span>}
</div>
)
}

View File

@@ -2,10 +2,12 @@ import React, { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { whepUrl, hlsUrl } from './api.js'
import { audio, effectiveMuted } from './useAudio.js'
import { names } from './names.js'
// WebRTC (WHEP) first, HLS fallback. Mute/volume come from the shared audio store.
export default function Player({ params, api }) {
const { id, user, title } = params
const custom = names.use(s => s.streams[id]) // viewer-side rename (tab double-click)
const whep = params.whep || id // Opus audio mirror path when the source audio is AAC
const videoRef = useRef(null)
const [status, setStatus] = useState('connecting…')
@@ -105,7 +107,7 @@ export default function Player({ params, api }) {
<div className="bar">
<span className="live">LIVE</span>
<span className="who">{user}</span>
<span className="title" title={title}>{title}</span>
<span className="title" title={title}>{custom || title}</span>
<span className="viewers">👁 {viewers}</span>
<button className={'mute' + (!muted ? ' on' : '') + (override === 'unmuted' && !focused ? ' pinned' : '')}
title={override ? (override === 'unmuted' ? 'audio pinned on (click to mute)' : 'muted (click to unmute)') : (focused ? 'focused: audio on (click to mute)' : 'muted: click to pin audio on')}

24
live/src/Tab.jsx Normal file
View File

@@ -0,0 +1,24 @@
import React, { useEffect, useRef, useState } from 'react'
import { names } from './names.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 }) {
const id = api.id
const custom = names.use(s => s.streams[id])
const base = `${params.user} · ${params.title}`
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
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) }
return (
<div className="vtab" title={`${base}\ndouble-click to rename`} onDoubleClick={start}>
{editing
? <input ref={ref} value={draft} placeholder={base} onChange={e => setDraft(e.target.value)}
onPointerDown={e => e.stopPropagation()} onBlur={commit}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(false) }} />
: <span className="vtab-text">{custom || base}</span>}
</div>
)
}

25
live/src/names.js Normal file
View File

@@ -0,0 +1,25 @@
import { create } from './tinyStore.js'
// Viewer-side labels. Stream names persist in this browser (localStorage, keyed by the MediaMTX path);
// group names live for the session (dockview group ids are regenerated on reload).
const KEY = 'vg.names'
let saved = {}
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}') || {} } catch {}
export const names = create({
streams: saved,
groups: {},
rename(id, value) {
const streams = { ...this.streams }
if (value) streams[id] = value; else delete streams[id]
try { localStorage.setItem(KEY, JSON.stringify(streams)) } catch {}
return { streams }
},
renameGroup(id, value) {
const groups = { ...this.groups }
if (value) groups[id] = value; else delete groups[id]
return { groups }
},
})
export const streamLabel = (id, params) => names.get().streams[id] || `${params.user} · ${params.title}`

View File

@@ -63,3 +63,11 @@ html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg)
.golive-stats .rec { background:#f1c40f; color:#000; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; }
.topbar a.signout { color:var(--mut); font-size:13px; text-decoration:none; border:1px solid #3a3a3a; border-radius:6px; padding:5px 10px; }
.topbar a.signout:hover { color:var(--fg); border-color:#666; }
/* renameable tabs + group names */
.vtab { display:flex; align-items:center; height:100%; padding:0 10px; min-width:0; }
.vtab-text { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:260px; }
.vtab input, .grouplabel input { background:#111; color:var(--fg); border:1px solid var(--acc); border-radius:4px; font:inherit; font-size:12px; padding:1px 6px; width:170px; outline:none; }
.grouplabel { display:flex; align-items:center; height:100%; padding:0 10px; font-size:12px; white-space:nowrap; border-right:1px solid #2a2a2a; cursor:default; }
.grouplabel .gname { color:var(--acc); font-weight:700; }
.grouplabel .hint { color:#555; }