openvibers/openvibe.live Commits
Go live setup is one screen: the ready card first, Suggest titles, folds that say what they hold, saved as you type (#543)
Changes
@@@ -81,6 +81,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)- [docs/go-live-in-your-browser.md](docs/go-live-in-your-browser.md) — Going live from a browser with no OBS and no follower minimum (the headline feature: keep it on the home page, in /llms.txt and in search metadata)- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)++- [docs/streams-api.md](docs/streams-api.md) — Stream slots API: everything the Go live screen does, as calls (slots, settings, key, title ideas, history, going live and ending)- [docs/rundown.md](docs/rundown.md) — The Rundown (`/rundown`, was the Arena): what streamers talked about, written up from their speech — topics, takes, episodes, crossovers- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token systemnnew file mode 100644@@@ -0,0 +1,79 @@++# Stream slots API++++Everything the Go live screen does is an API call: list your slots, make one, change its title or settings, get its++server and key, ask for title ideas, see its past streams, and end a stream. A phone app, a bot or a tool of your own++goes live the same way the page does.++++A **slot** is one stream of yours. It holds the title, the link (`openvibe.live/@you/<slug>`), how it goes out (your++browser, OBS over RTMP, WHIP or FFmpeg), its restreams and its recording settings. Each slot has its own stream key.++++## Signing in++++Send `Authorization: Bearer <token>` with every call.++++- The token is an OpenVibe.Network sign-in, or an API token made under Settings → API tokens (see++ [API tokens](api-tokens.md)).++- An API token needs the `read` scope for reads and the `stream` scope for changes.++- `GET …/profile` answers with the slot's `stream_key`. A token that can read it can publish to the slot, so keep that++ token as safe as the key itself.++++Every path below starts with `https://openvibe.live/api/streams`.++++In JavaScript, [`openvibe-sdk/live`](https://github.com/OpenVibers/OpenVibe.SDK) wraps these calls:++`live.slots.list()`, `live.slots.create()`, `live.slots.update()`, `live.slots.profile()`, `live.slots.history()`,++`live.slots.quality()`, `live.slots.titleIdeas()`, `live.goLive()`, `live.streams.end()` and the WHIP helpers.++++## Slots++++| Call | What it does |++|------|--------------|++| `GET /managed` | Your slots: `{ managed_streams: [{ id, title, slug, description, is_nsfw, streaming_method, browser_mode, … }] }` |++| `POST /managed` | Make a slot: `{ title, description?, slug?, is_nsfw?, streaming_method? }` → `{ managed_stream }`. The number of slots per account is limited; the error says the limit. |++| `PUT /managed/:id` | Change a slot. Send only what changes: `title`, `description`, `slug`, `is_nsfw`, `streaming_method` (`browser`, `rtmp`, `whip`, `cli`), `browser_mode` (`camera`, `camera_only`, `mic_only`, `screen`), `default_vod_visibility` and `default_clip_visibility` (`public`, `unlisted`, `private`), `slot_vod_recording_enabled`, `slot_clip_recording_enabled`, `slot_clip_notify_enabled` (`1` or `0`), `weather_zip` and `weather_detail`. |++| `DELETE /managed/:id` | Delete a slot. A live slot cannot be deleted (409): end its stream first. |++| `GET /managed/:id/profile` | All of a slot in one call: the slot, its quality settings (`broadcast_settings`), its restreams, its RTMP server (`rtmp_url`), its WHIP base (`whip_url_base`) and its `stream_key`. |++| `PUT /broadcast-settings` | A slot's quality: `{ managed_stream_id, settings: { resolution: "1080", fps: "30", bitrate: 4500 } }` (the bitrate is in kbps). |++| `POST /managed/:id/regenerate-key` | A new stream key for the slot. The old one stops working at once. |++| `GET /managed/:id/history` | The slot's past streams, newest first: `{ sessions: [{ id, title, started_at, duration_seconds, peak_viewers, vod_id, … }] }` |++| `POST /managed/:id/title-ideas` | Three title ideas for the next stream, written from the slot's last streams: `{ weekday?: "Sunday", hour?: 21, title?: "what is typed now" }` → `{ titles: ["…", "…", "…"] }`. A few calls a minute at most (429 says to wait). It uses your own AI allowance, and nothing is stored. |++++## Going live++++How a slot goes live depends on its `streaming_method`.++++- **Starting a session from a slot:** `POST /` with `{ managed_stream_id, title? }` returns `{ stream, endpoint }`.++ This is what the Go live button does before your browser connects. An encoder that connects by itself starts one on++ its own.++++- **WHIP:** `POST https://openvibe.live/whip/<slot id>` with the slot's key as the Bearer token and your SDP offer.++ It works from any WHIP client, including a browser page of your own. The [WHIP guide](whip.md) has the whole++ session: the endpoint, its lifecycle, errors and CORS.++- **RTMP:** point OBS, Streamlabs or IRL Pro at the slot's `rtmp_url`, with its `stream_key` as the key. The slot goes++ live when the app connects and ends when it stops. Nothing is pressed here.++- **FFmpeg / CLI:** the Go live page's "Stream from an app instead" row shows the commands for the slot, with its key.++ Most tools are simplest over RTMP or WHIP.++- **Browser:** the Go live page itself, with your camera, microphone or screen. A program that wants to publish from a++ browser uses WHIP.++++`GET /mine` lists your streams, newest first. A live one has `is_live: true`, and its `id` is the stream's.++++## Ending a stream++++- `DELETE /:streamId` ends a live stream. The recording is finished and kept, following the slot's recording++ settings.++- An encoder can also just stop. A WHIP client sends `DELETE` to the session URL its `POST` returned.++- A stream whose broadcaster drops stays live for five minutes, so a phone that loses signal comes back to the same++ stream.++++## Errors++++Errors come back as `{ "error": "…" }` in plain words:++++| Code | Meaning |++|------|---------|++| 400 | A field is not valid (the message says which). |++| 401 | No sign-in, or it expired. |++| 403 | Not your slot, or the token's scopes do not allow the call. |++| 404 | No such slot or stream. |++| 409 | That short link is taken on your account, or the slot is live and cannot be deleted. |++| 429 | Too many calls: wait a little. |@@@ -3142,3 +3142,132 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna.bcs-livecard .bcs-gl-link code { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; font-size: 1.05rem; }.bcs-livecard .bcs-gl-copy { flex: 1; justify-content: center; height: 46px; }}++++/* ── Go live setup, one screen (2026-11-02; the owner: "this layout ain't too great… more intelligent… more modular").++ The slots are a row of pills. The ready card comes first: the title, chips for what is set, the devices, what viewers++ will get and Go live (an encoder's slot: waiting, with its address and key). Every other setting is a fold whose line++ says what it holds. On a wide screen: two columns, the card sticky on the left. Changes save as they are made. ── */++.bc-workspace { flex-direction: column; align-items: stretch; gap: 12px; }++.bc-ws-sidebar.gl-slots { width: auto; position: static; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; overflow: visible; }++.gl-slots .bc-ws-list { display: flex; flex-direction: row; gap: 8px; overflow-x: auto; overflow-y: visible; padding: 2px 2px 6px; scrollbar-width: none; }++.gl-slots .bc-ws-list::-webkit-scrollbar { display: none; }++.gl-slot { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 40px; max-width: 240px; padding: 0 14px;++ border-radius: 999px; cursor: pointer; font-size: 0.88rem; font-weight: 650; color: var(--text-secondary);++ background: color-mix(in srgb, var(--bg-card) 92%, transparent); border: 1px solid var(--border);++ transition: background-color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s); }++.gl-slot i { font-size: 0.85rem; color: var(--text-muted); }++.gl-slot:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }++.gl-slot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.gl-slot.is-on { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 14%, var(--bg-card)); border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }++.gl-slot.is-on i { color: var(--accent); }++.gl-slot-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.gl-slot-live { flex: none; padding: 2px 7px; border-radius: 999px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: var(--live-red, #e11d48); color: #fff; }++.gl-slot--add { color: var(--accent); border-style: dashed; background: transparent; }++.gl-slot--add i { color: var(--accent); }++@media (pointer: coarse) { .gl-slot { min-height: 44px; } }++++.bc-workspace > .bc-ws-main { border: 0; border-radius: 0; background: none; box-shadow: none; }++.gl { display: grid; gap: 12px; align-items: start; min-width: 0; }++@container bcws (min-width: 900px) {++ .gl { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 18px; }++ .gl-card { position: sticky; top: 8px; }++}++++.gl-card { min-width: 0; padding: 16px; border-radius: 18px; border: 1px solid var(--border);++ background: color-mix(in srgb, var(--bg-card) 94%, transparent); box-shadow: 0 1px 0 color-mix(in srgb, var(--text-primary) 4%, transparent) inset; }++.gl-title-row { display: flex; align-items: center; gap: 12px; min-width: 0; }++.gl-title { flex: 1 1 auto; min-width: 0; width: 100%; box-sizing: border-box; margin: -5px -8px; padding: 5px 8px; font: inherit; font-size: 1.4rem; font-weight: 800;++ line-height: 1.25; letter-spacing: -0.01em; color: var(--text-primary); background: transparent; border: 1px solid transparent; border-radius: 10px;++ transition: border-color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }++.gl-title:hover { border-color: var(--border); }++.gl-title:focus { outline: none; border-color: var(--accent); background: var(--bg-input, var(--bg-primary)); }++.gl-title::placeholder { color: var(--text-muted); font-weight: 700; }++.gl-title-row .bc-ws-save-status { flex: none; min-width: 0; font-size: 0.78rem; font-weight: 650; font-style: normal; text-align: right; color: var(--text-muted); white-space: nowrap; }++.gl-title-row .bc-ws-save-status.bc-ws-saved::before { content: "\2713\00a0"; color: var(--success-text, var(--success, #16a34a)); }++.gl-title-row .bc-ws-save-status.bc-ws-save-error { flex: 0 1 50%; white-space: normal; color: var(--danger-text, var(--danger)); }++.gl-retry { font: inherit; font-weight: 700; color: var(--accent); background: none; border: 0; padding: 4px 2px; cursor: pointer; text-decoration: underline; }++++.gl-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 14px; min-width: 0; }++.gl-chip { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; max-width: 100%; padding: 0 12px;++ border-radius: 999px; cursor: pointer; font-size: 0.82rem; font-weight: 600; color: var(--text-secondary);++ background: color-mix(in srgb, var(--text-primary) 5%, transparent); border: 1px solid var(--border);++ transition: border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }++.gl-chip i { flex: none; font-size: 0.8rem; color: var(--text-muted); }++.gl-chip b { flex: none; font-weight: 750; color: var(--text-primary); }++.gl-chip span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.gl-chip:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }++.gl-chip:focus-visible, .gl-chip:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }++.gl-chip--mature input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }++.gl-chip--mature:has(input:checked) { color: var(--danger-text, var(--danger)); border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); background: color-mix(in srgb, var(--danger) 9%, transparent); }++.gl-chip--mature:has(input:checked) i, .gl-chip--mature:has(input:checked) b { color: var(--danger-text, var(--danger)); }++.gl-chip--link { flex: 0 1 auto; min-width: 0; }++.gl-chip--link i { color: var(--accent); }++@media (pointer: coarse) { .gl-chip { min-height: 44px; } }++++/* The go-live block sits in the card without a frame of its own; the room for the phone's chat bubble moves to the end. */++.gl-card .bc-ws-golive-section { margin: 0; padding: 0; border: 0; }++.gl-card .bc-ws-golive-section .bc-ws-golive-btn { margin-top: 12px; }++@container bcws (min-width: 960px) { .gl-card .bc-ws-golive-section.is-browser > * { max-width: none; } }++@media (max-width: 680px) { .gl-card .bc-ws-golive-section.is-browser { padding-bottom: 0; } .gl-folds { padding-bottom: 76px; } }++++/* An encoder's slot: waiting, in words, then its address and key. */++.gl-wait { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 12px; border-radius: 14px;++ background: color-mix(in srgb, var(--accent) 7%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); }++.gl-wait b { display: block; font-size: 0.95rem; color: var(--text-primary); }++.gl-wait small { display: block; margin-top: 2px; font-size: 0.8rem; line-height: 1.45; color: var(--text-secondary); }++.gl-wait-dot { flex: none; width: 10px; height: 10px; margin-top: 6px; border-radius: 50%; background: var(--accent);++ box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); animation: gl-breathe 2.4s var(--ov-ease-in-out, ease-in-out) infinite; }++@keyframes gl-breathe { 50% { opacity: 0.4; } }++.gl-card .bc-ws-endpoint-section, .gl-fold .bc-ws-endpoint-section { margin: 0; padding: 0; border: 0; border-radius: 0; background: none; }++++/* The folds: one row each, what it holds on the right, opened in place. */++.gl-folds { display: grid; gap: 8px; min-width: 0; align-content: start; }++.gl-fold { min-width: 0; padding: 0; margin: 0; border-radius: 14px; border: 1px solid var(--border); background: color-mix(in srgb, var(--bg-card) 94%, transparent); overflow: hidden; }++.gl-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 8px 14px; cursor: pointer; list-style: none; user-select: none;++ font-size: 0.9rem; font-weight: 650; color: var(--text-primary); transition: background-color var(--ov-dur-2, .15s); }++.gl-fold > summary::-webkit-details-marker { display: none; }++.gl-fold > summary:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }++.gl-fold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }++.gl-fold > summary > i:first-child { flex: none; width: 18px; margin: 0; text-align: center; color: var(--accent); }++.gl-fold > summary .bc-sum-title { flex: none; white-space: nowrap; }++.gl-fold > summary .bc-sum-val { flex: 1 1 auto; min-width: 0; margin-left: auto; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.gl-fold[open] > summary { border-bottom: 1px solid var(--border); }++.gl-fold[open] > summary .bc-sum-chev { transform: rotate(180deg); color: var(--accent); }++.gl-fold-body { padding: 12px 14px 14px; min-width: 0; }++.gl-fold > .bc-ws-slot-settings-inner { padding: 12px 14px 14px; }++.gl-fold-body > .form-group:last-of-type { margin-bottom: 6px; }++.gl-danger-link { font: inherit; font-size: 0.84rem; font-weight: 650; color: var(--danger-text, var(--danger)); background: none; border: 0; padding: 8px 0 2px; cursor: pointer; }++.gl-danger-link:hover { text-decoration: underline; }++++/* Opening a slot: three quiet dots, never a spinning ring. */++.gl-loading { display: flex; justify-content: center; gap: 6px; padding: 40px 0; }++.gl-loading span { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); animation: gl-breathe 1.2s var(--ov-ease-in-out, ease-in-out) infinite; }++.gl-loading span:nth-child(2) { animation-delay: 0.2s; }++.gl-loading span:nth-child(3) { animation-delay: 0.4s; }++@media (prefers-reduced-motion: reduce) { .gl-wait-dot, .gl-loading span { animation: none; } .gl-slot, .gl-chip, .gl-title { transition: none; } }++/* Video quality is a fold too: the same row as the others (its older rule had its own padding). */++.gl-fold.bc-ws-quality > summary { padding: 8px 14px; min-height: 52px; font-size: 0.9rem; }++.gl-fold.bc-ws-quality > summary > i:first-child { width: 18px; }++.gl-fold .bc-sum-chev { flex: none; font-size: 0.75rem; }++++/* ✨ Suggest beside the title, and its three ideas under it: a tap puts one in the title. */++.gl-suggest { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; padding: 0 12px; border-radius: 999px; cursor: pointer;++ font-size: 0.82rem; font-weight: 700; color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));++ background: color-mix(in srgb, var(--accent) 8%, transparent); transition: background-color var(--ov-dur-2, .15s); }++.gl-suggest:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }++.gl-suggest:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++@media (pointer: coarse) { .gl-suggest { min-height: 44px; } }++.gl-ideas { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }++.gl-ideas[hidden] { display: none; }++.gl-idea { all: unset; box-sizing: border-box; max-width: 100%; padding: 8px 12px; border-radius: 12px; cursor: pointer; font-size: 0.86rem; font-weight: 600; line-height: 1.3;++ color: var(--text-primary); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); overflow-wrap: anywhere; }++.gl-idea:hover { border-style: solid; }++.gl-idea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.gl-ideas-note { margin: 0; font-size: 0.8rem; color: var(--text-muted); }++/* A phone: the saved note drops under the title so the title keeps its width beside Suggest. */++@media (max-width: 420px) {++ .gl-title-row { flex-wrap: wrap; row-gap: 4px; }++ .gl-title-row .gl-title { flex: 1 1 0; }++ .gl-title-row .bc-ws-save-status { order: 3; flex-basis: 100%; text-align: left; }++ .gl-title-row .bc-ws-save-status:empty { display: none; }++}@@@ -18,7 +18,7 @@<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ --><div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live"><h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</button></h2>-- <p class="bc-page-subtitle">Select a stream slot to configure your profile and go live.</p>++ <p class="bc-page-subtitle">Check the card, press Go live. Everything else is in the rows under it.</p><!-- Guests: going live needs an account. broadcast.js shows this in place of the slotworkspace and loads nothing else (_broadcastSignInGate). -->@@@ -52,26 +52,17 @@<div class="bc-top-tab-panel active" data-bctabpanel="mine"><div class="bc-workspace" id="bc-workspace">-- <!-- Left sidebar: stream slot list -->-- <div class="bc-ws-sidebar">-- <div class="bc-ws-sidebar-hd">-- <span><i class="fa-solid fa-tower-broadcast"></i> My Streams</span>-- <button class="btn btn-small btn-ghost bc-ws-add-btn"-- onclick="showCreateManagedStreamModal()" title="Add stream slot">-- <i class="fa-solid fa-plus"></i>-- </button>-- </div>-- <div class="bc-ws-list" id="bc-ws-list">-- <p class="muted" style="padding:12px"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</p>-- </div>-- </div>++ <!-- Your slots: a row of pills (method icon, name, live), then New slot (broadcast-workspace.js) -->++ <nav class="bc-ws-sidebar gl-slots" aria-label="Your stream slots">++ <div class="bc-ws-list" id="bc-ws-list"></div>++ </nav><!-- Right main panel --><div class="bc-ws-main"><!-- Empty state: no stream selected --><div class="bc-ws-empty" id="bc-ws-empty"><i class="fa-solid fa-tower-broadcast" style="font-size:2.5rem;opacity:0.2;margin-bottom:16px"></i>-- <p class="muted" style="margin:0 0 16px">Select a stream slot from the sidebar to configure and go live</p>++ <p class="muted" style="margin:0 0 16px">A slot is one stream of yours: its title, its link, how it goes out and where else it is sent. Make one to go live.</p><button class="btn btn-outline" onclick="showCreateManagedStreamModal()"><i class="fa-solid fa-plus"></i> Create stream slot</button>@@@ -79,32 +79,22 @@ async function _wsLoadManagedStreams() {function _wsRenderSidebar() {const list = document.getElementById('bc-ws-list');if (!list) return;--++ const add = '<button type="button" class="gl-slot gl-slot--add" onclick="showCreateManagedStreamModal()"><i class="fa-solid fa-plus" aria-hidden="true"></i><span>New slot</span></button>';if (_wsState.managedStreams.length === 0) {-- list.innerHTML = '<p class="bc-ws-empty-hint muted">No stream slots yet.<br>Click <strong>+</strong> to create your first one.</p>';++ list.innerHTML = add;return;}--list.innerHTML = _wsState.managedStreams.map(ms => {const isSelected = ms.id === _wsState.selectedId;const isLive = _wsIsManagedStreamLive(ms.id);-- const proto = _wsMethodLabel(ms.streaming_method || ms.protocol);-- return `-- <div class="bc-ws-item${isSelected ? ' selected' : ''}" onclick="wsSelectStream(${ms.id})">-- <div class="bc-ws-item-status${isLive ? ' live' : ''}"></div>-- <div class="bc-ws-item-body">-- <div class="bc-ws-item-name">${esc(ms.title || 'Untitled')}</div>-- <div class="bc-ws-item-meta">-- <span class="bc-ws-item-proto">${esc(proto)}</span>-- ${ms.slug ? `<span class="bc-ws-item-slug">/${esc(ms.slug)}</span>` : `<span class="bc-ws-item-slug">#${ms.id}</span>`}-- ${isLive ? '<span class="bc-ws-live-dot"><i class="fa-solid fa-circle"></i> LIVE</span>' : ''}-- </div>-- </div>-- <button class="bc-ws-item-del" onclick="event.stopPropagation();_wsConfirmDelete(${ms.id})" title="Delete slot">-- <i class="fa-solid fa-xmark"></i>-- </button>-- </div>`;-- }).join('');++ const method = ms.streaming_method || ms.protocol;++ return `<button type="button" class="gl-slot${isSelected ? ' is-on' : ''}${isLive ? ' is-live' : ''}" aria-pressed="${isSelected}" onclick="wsSelectStream(${ms.id})"++ title="${esc(_wsMethodLabel(method))}${ms.slug ? ' · /' + esc(ms.slug) : ''}">++ <i class="fa-solid fa-${_glMethodIcon(method, ms.browser_mode)}" aria-hidden="true"></i>++ <span class="gl-slot-name">${esc(ms.title || 'Untitled')}</span>++ ${isLive ? '<span class="gl-slot-live">Live</span>' : ''}++ </button>`;++ }).join('') + add;}/** Human-readable method label for sidebar/panel */@@@ -132,12 +122,7 @@ function _wsIsManagedStreamLive(managedStreamId) {// Public – called from sidebar onclickasync function wsSelectStream(managedStreamId) {if (_wsState.selectedId === managedStreamId) return;-- if (_wsState.dirty) {-- const action = await _wsConfirmUnsaved('You have unsaved changes. What would you like to do?');-- if (action === 'save') await _wsSaveAll();-- if (action === 'cancel') return;-- // 'discard' falls through-- }++ await _wsFlushSave();await _wsSelectStream(managedStreamId);}@@@ -154,7 +139,7 @@ async function _wsSelectStream(managedStreamId) {if (empty) empty.style.display = 'none';if (panel) {panel.style.display = '';-- panel.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-muted)"><i class="fa-solid fa-spinner fa-spin"></i> Loading stream profile\u2026</div>';++ panel.innerHTML = '<div class="gl-loading" aria-busy="true"><span></span><span></span><span></span></div>';}await _wsLoadProfile(managedStreamId);@@@ -231,27 +216,41 @@ function _wsRenderPanel() {// Determine browser submode from managed stream or profileconst browserMode = ms.browser_mode || p.browserMode || 'camera';-- panel.innerHTML = `-- <!-- ── Panel header ── -->-- <div class="bc-ws-panel-hd">-- <div class="bc-ws-panel-title">-- <h2>${esc(ms.title || 'Untitled Stream')}</h2>-- <span class="bc-ws-slug muted">-- ${currentUser?.username-- ? `<a href="${channelPath(currentUser.username, ms.slug || ms.id)}" target="_blank" style="color:var(--text-muted);text-decoration:none"><i class="fa-solid fa-link" style="font-size:0.7rem"></i> openvibe.live${channelPath(currentUser.username, ms.slug || ms.id)}</a>`-- : ms.slug-- ? `<i class="fa-solid fa-link" style="font-size:0.7rem"></i> openvibe.live/${esc(ms.slug)}`-- : `<i class="fa-solid fa-hashtag" style="font-size:0.7rem"></i> Stream #${ms.id}`}-- </span>-- </div>-- <div style="display:flex;align-items:center;gap:8px">-- ${isLive ? '<span class="bc-live-badge bc-ws-live-badge-hd"><i class="fa-solid fa-circle"></i> LIVE</span>' : ''}-- <button class="btn btn-small btn-ghost bc-ws-popout-chat-btn" onclick="_wsOpenPopoutChat()" title="Popout Chat">-- <i class="fa-solid fa-comment-dots"></i> Chat-- </button>-- </div>-- </div>++ const link = currentUser?.username++ ? `openvibe.live${channelPath(currentUser.username, ms.slug || ms.id)}`++ : (ms.slug ? `openvibe.live/${ms.slug}` : `Stream #${ms.id}`);++ const isBrowser = method === 'browser';++ const endpoint = ` <div class="bc-ws-endpoint-section">++ <div class="bc-ws-endpoint-hd">++ <h3><i class="fa-solid fa-plug"></i> Your server and key</h3>++ </div>++ <p class="bc-ws-endpoint-note">++ This key belongs to <strong>${esc(ms.title || 'this stream slot')}</strong>.++ Each slot has its own stable key. Regenerating invalidates the current key immediately.++ </p>++ <div class="bc-ws-key-row">++ <div class="bc-ws-key-display">++ <input type="password" id="bc-ws-stream-key" class="form-input form-input-sm bc-ws-key-input"++ value="${esc(streamKey)}" readonly autocomplete="off" spellcheck="false"++ aria-label="Stream key">++ <button class="btn btn-small btn-ghost bc-ws-key-toggle"++ onclick="_wsToggleKeyVisibility()" title="Show / hide key">++ <i class="fa-solid fa-eye" id="bc-ws-key-eye"></i>++ </button>++ <button class="btn btn-small btn-ghost"++ onclick="_wsCopyStreamKey()" title="Copy stream key">++ <i class="fa-solid fa-copy"></i>++ </button>++ </div>++ <button class="btn btn-small btn-outline bc-ws-regen-btn"++ onclick="_wsRegenerateKey(${ms.id})">++ <i class="fa-solid fa-arrows-rotate"></i> Regen Key++ </button>++ </div>++ <div id="bc-ws-method-endpoint">${_wsRenderMethodEndpoint(method, streamKey, ms.id)}</div>++ </div>`;++ panel.innerHTML = `<!-- ── Live session banner (expanded with stats) ── --><div id="bc-ws-live-banner" style="${liveSessionId ? '' : 'display:none'}"><div class="bc-ws-live-banner">@@@ -293,35 +292,128 @@ function _wsRenderPanel() {</div></div>-- <!-- ── Tabs ── -->-- <div class="bc-ws-tabs" id="bc-ws-tabs">-- <button class="bc-ws-tab active" data-wstab="profile" onclick="_wsTabSwitch('profile')">-- <i class="fa-solid fa-sliders"></i> Stream-- </button>-- <button class="bc-ws-tab" data-wstab="settings" onclick="_wsTabSwitch('settings')">-- <i class="fa-solid fa-gear"></i> Settings-- </button>-- <button class="bc-ws-tab" data-wstab="endpoint" onclick="_wsTabSwitch('endpoint')">-- <i class="fa-solid fa-plug"></i> Endpoint-- </button>-- <button class="bc-ws-tab" data-wstab="history" onclick="_wsTabSwitch('history')">-- <i class="fa-solid fa-clock-rotate-left"></i> History-- </button>-- </div>---- <!-- ═══ TAB: Stream Profile ═══ -->-- <div class="bc-ws-tab-panel active" data-wstabpanel="profile">-- <div class="bc-ws-profile-section bc-ws-profile-split">-- <div class="bc-ws-col bc-ws-col--details">++ <div class="gl">++ <section class="gl-card${isBrowser ? ' is-browser' : ''}" aria-label="Your stream, ready to go live">++ <div class="gl-title-row">++ <input type="text" id="bc-title" class="gl-title" aria-label="Title"++ value="${esc(ms.title || '')}"++ placeholder="What are you streaming?" maxlength="200"++ oninput="_wsMarkDirty()">++ <span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>++ <button type="button" class="gl-suggest" id="gl-suggest" onclick="_glSuggestTitles(this)" title="Three title ideas from your last streams on this slot">++ <span aria-hidden="true">\u2728</span> Suggest++ </button>++ </div>++ <div class="gl-ideas" id="gl-ideas" role="group" aria-label="Title ideas" hidden></div>++ <div class="gl-chips">++ <label class="gl-chip gl-chip--mature" title="Mature (18+): viewers confirm their age before the stream plays. The category is set automatically from what is on stream.">++ <input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()">++ <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i><span>Mature</span><b id="gl-mature-v">${ms.is_nsfw ? 'on' : 'off'}</b>++ </label>++ <button type="button" class="gl-chip gl-chip--link" onclick="_glCopyLink()" title="Copy your stream's address">++ <i class="fa-solid fa-link" aria-hidden="true"></i><span id="gl-link">${esc(link)}</span>++ </button>++ <button type="button" class="gl-chip" onclick="_glOpenFold('bc-ws-restream-details')" title="Where this stream is sent too">++ <i class="fa-solid fa-share-nodes" aria-hidden="true"></i><span>Restreams</span><b id="gl-rs-v">…</b>++ </button>++ <button type="button" class="gl-chip" onclick="_wsOpenPopoutChat()" title="Open your chat in its own window">++ <i class="fa-solid fa-comment-dots" aria-hidden="true"></i><span>Chat</span>++ </button>++ </div>-- <div class="form-group">-- <label>Title</label>-- <input type="text" id="bc-title" class="form-input"-- value="${esc(ms.title || '')}"-- placeholder="What are you streaming?" maxlength="200"-- oninput="_wsMarkDirty()">++ ${isBrowser ? `++ <div class="bc-ws-golive-section is-browser" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>++ <!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens and++ the microphone; remembered on this device, never saved with the slot. -->++ <div id="bc-ws-device-wrap">++ <div id="bc-perm-request" class="bc-perm-request">++ <p class="bc-perm-hint"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>++ <span id="bc-perm-hint-text">${_wsPermHint(browserMode)}</span></p>++ <button id="bc-perm-btn" class="btn btn-outline" type="button"++ onclick="requestMediaPermissions()">++ <i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'++ : browserMode === 'screen' ? 'fa-display'++ : 'fa-video'}"></i>++ <span id="bc-perm-btn-text">${browserMode === 'mic_only' ? 'Allow Microphone'++ : browserMode === 'camera_only' ? 'Allow Camera'++ : browserMode === 'screen' ? 'Allow Screen Share'++ : 'Allow Camera & Mic'}</span>++ </button>++ <p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>++ </div>++ <div id="bc-device-selects" style="display:none">++ <div class="bc-setup-card">++ <div class="bc-setup-item" id="bc-ws-camera-group">++ <div class="bc-setup-row">++ <span class="bc-setup-label"><i class="fa-solid fa-camera" aria-hidden="true"></i>Camera</span>++ <div class="bc-cam-select-row bc-setup-pick">++ <span class="bc-setup-val" data-pick-for="bc-create-camera">Default</span>++ <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>++ <select id="bc-create-camera" class="bc-setup-native" aria-label="Camera">++ <option value="default">Default</option>++ </select>++ </div>++ </div>++ </div>++ <div class="bc-setup-item" id="bc-ws-audio-group">++ <div class="bc-setup-row">++ <span class="bc-setup-label"><i class="fa-solid fa-microphone" aria-hidden="true"></i>Microphone</span>++ <div class="bc-setup-pick">++ <span class="bc-setup-val" data-pick-for="bc-create-audio">Default</span>++ <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>++ <select id="bc-create-audio" class="bc-setup-native" aria-label="Microphone">++ <option value="default">Default</option>++ </select>++ </div>++ </div>++ </div>++ </div>++ <p class="bc-setup-note">++ <span>Remembered on this device</span>++ <button type="button" class="bc-setup-link" onclick="refreshDeviceLists()"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Refresh devices</button>++ </p>++ </div></div>++ <!-- Screen-share hidden defaults -->++ <input type="checkbox" id="bc-screen-mic-enabled" checked style="display:none">++ <input type="checkbox" id="bc-screen-cam-enabled" style="display:none">++ <input type="checkbox" id="bc-screenPreferCurrentTab-create" style="display:none">++ <select id="bc-screenSystemAudio-create" style="display:none"><option value="include" selected>Include</option><option value="auto">Auto</option><option value="exclude">Exclude</option></select>++ <select id="bc-screenSelfBrowser-create" style="display:none"><option value="exclude" selected>Exclude</option><option value="include">Include</option></select>++ <select id="bc-screenSurfaceSwitching-create" style="display:none"><option value="include" selected>Allow</option><option value="exclude">Disallow</option></select>++ <select id="bc-screen-audio" style="display:none"><option value="default">Default</option></select>++ <select id="bc-screen-camera" style="display:none"><option value="default">Default</option></select>++++ ${_wsPreflight(browserMode)}++++ <button class="btn btn-primary btn-lg bc-ws-golive-btn"++ onclick="goLiveFromWorkspace()" id="bc-ws-golive-btn">++ <i class="fa-solid fa-tower-broadcast"></i> Go Live++ </button>++ <p class="bc-create-reassurance" style="margin-top:6px;font-size:0.82rem">++ <i class="fa-solid fa-circle-info"></i>++ Live at <strong>openvibe.live${channelPath(currentUser?.username || 'your-channel', ms.slug || ms.id)}</strong>++ </p>++ </div>++ ` : `++ <div class="gl-wait" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>++ <span class="gl-wait-dot" aria-hidden="true"></span>++ <div>++ <b>Waiting for your ${method === 'rtmp' ? 'streaming app' : method === 'whip' ? 'WHIP encoder' : 'encoder'}…</b>++ <small>This stream goes live by itself when ${method === 'rtmp' ? 'your app connects over RTMP'++ : method === 'whip' ? 'your encoder connects over WHIP'++ : 'FFmpeg or your tool sends to the address below'}. Nothing to press here.</small>++ </div>++ </div>++ ${endpoint}++ `}++ </section>++++ <div class="gl-folds">++ <details class="gl-fold" id="gl-details">++ <summary><i class="fa-solid fa-pen" aria-hidden="true"></i><span class="bc-sum-title">Details</span><small class="bc-sum-val" id="gl-details-sum">${_glDetailsSummary(ms)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>++ <div class="gl-fold-body"><div class="form-group"><label>Description</label><textarea id="bc-description" class="form-input" rows="2"@@@ -329,13 +421,6 @@ function _wsRenderPanel() {oninput="_wsMarkDirty()">${esc(ms.description || '')}</textarea></div>-- <!-- No category picker: the AI decides it from the stream itself (streams.ai_category wins everywhere). -->-- <label class="bc-switch">-- <input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()">-- <span class="bc-switch-track" aria-hidden="true"><span class="bc-switch-thumb"></span></span>-- <span class="bc-switch-text"><b><i class="fa-solid fa-triangle-exclamation"></i> Mature content (NSFW)</b><small>Viewers confirm their age before the stream plays. The category is set automatically from what is on stream.</small></span>-- </label>--<!-- URL Slug --><div class="form-group"><label>URL Slug <span class="muted">(optional)</span></label>@@@ -346,12 +431,16 @@ function _wsRenderPanel() {oninput="_wsMarkDirty()"></div></div>++ <button type="button" class="gl-danger-link" onclick="_wsConfirmDelete(${ms.id})"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete this slot</button>++ </div>++ </details>-- </div>-- <div class="bc-ws-col bc-ws-col--method">++ <details class="gl-fold" id="gl-how">++ <summary><i class="fa-solid fa-${_glMethodIcon(method, browserMode)}" aria-hidden="true"></i><span class="bc-sum-title">How you stream</span><small class="bc-sum-val" id="gl-how-sum">${esc(_glHowSummary(method, browserMode))}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>++ <div class="gl-fold-body bc-ws-col--method"><!-- ═══ Streaming Method ═══ --><div class="form-group">-- <label>Streaming Method</label>++ <label>Where the picture comes from</label><div class="bc-method-picker bc-method-picker-sm" id="bc-ws-method-picker" role="radiogroup" aria-label="Streaming method">${_wsRenderMethodCards(method)}</div>@@@ -361,7 +450,7 @@ function _wsRenderPanel() {<!-- ═══ Browser Sub-mode (only when method is browser) ═══ --><div id="bc-ws-browser-submodes" style="${method === 'browser' ? '' : 'display:none'}"><div class="form-group">-- <label>Browser Capture Mode</label>++ <label>What your browser sends</label><div class="bc-method-picker bc-method-picker-sm" id="bc-ws-browser-mode-picker" role="radiogroup" aria-label="Browser capture mode">${_wsRenderBrowserModeCards(browserMode)}</div>@@@ -512,9 +601,12 @@ function _wsRenderPanel() {</div></div>++ </div>++ </details>++<!-- Video quality defaults (Class A blob, browser method only) --><div id="bc-ws-quality-wrap" style="${method === 'browser' ? '' : 'display:none'}">-- <details class="bc-ws-quality">++ <details class="gl-fold bc-ws-quality" id="gl-quality"><summary><i class="fa-solid fa-film"></i><span class="bc-sum-title">Video quality</span><small class="bc-sum-val">${_wsQualitySummaryHTML(p.resolution || '720', p.fps || '30', p.bitrate || 2500)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-quality-inner bc-ws-row"><div class="form-group" style="margin:0;flex:1">@@@ -546,116 +638,9 @@ function _wsRenderPanel() {</details></div>-- </div>---- <!-- Hidden element required by createNewStream() -->-- <select id="bc-managed-stream" style="display:none">-- <option value="${ms.id}" selected>${esc(ms.title || '')}</option>-- </select>-- </div>---- <!-- ── Go Live / Auto-detect section ── -->-- <div class="bc-ws-golive-section${method === 'browser' ? ' is-browser' : ''}" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>---- ${method === 'browser' ? `-- <!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens and-- the microphone; remembered on this device, never saved with the slot. -->-- <div id="bc-ws-device-wrap">-- <div id="bc-perm-request" class="bc-perm-request">-- <p class="bc-perm-hint"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>-- <span id="bc-perm-hint-text">${_wsPermHint(browserMode)}</span></p>-- <button id="bc-perm-btn" class="btn btn-outline" type="button"-- onclick="requestMediaPermissions()">-- <i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'-- : browserMode === 'screen' ? 'fa-display'-- : 'fa-video'}"></i>-- <span id="bc-perm-btn-text">${browserMode === 'mic_only' ? 'Allow Microphone'-- : browserMode === 'camera_only' ? 'Allow Camera'-- : browserMode === 'screen' ? 'Allow Screen Share'-- : 'Allow Camera & Mic'}</span>-- </button>-- <p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>-- </div>-- <div id="bc-device-selects" style="display:none">-- <div class="bc-setup-card">-- <div class="bc-setup-item" id="bc-ws-camera-group">-- <div class="bc-setup-row">-- <span class="bc-setup-label"><i class="fa-solid fa-camera" aria-hidden="true"></i>Camera</span>-- <div class="bc-cam-select-row bc-setup-pick">-- <span class="bc-setup-val" data-pick-for="bc-create-camera">Default</span>-- <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>-- <select id="bc-create-camera" class="bc-setup-native" aria-label="Camera">-- <option value="default">Default</option>-- </select>-- </div>-- </div>-- </div>-- <div class="bc-setup-item" id="bc-ws-audio-group">-- <div class="bc-setup-row">-- <span class="bc-setup-label"><i class="fa-solid fa-microphone" aria-hidden="true"></i>Microphone</span>-- <div class="bc-setup-pick">-- <span class="bc-setup-val" data-pick-for="bc-create-audio">Default</span>-- <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>-- <select id="bc-create-audio" class="bc-setup-native" aria-label="Microphone">-- <option value="default">Default</option>-- </select>-- </div>-- </div>-- </div>-- </div>-- <p class="bc-setup-note">-- <span>Remembered on this device</span>-- <button type="button" class="bc-setup-link" onclick="refreshDeviceLists()"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Refresh devices</button>-- </p>-- </div>-- </div>---- <!-- Screen-share hidden defaults -->-- <input type="checkbox" id="bc-screen-mic-enabled" checked style="display:none">-- <input type="checkbox" id="bc-screen-cam-enabled" style="display:none">-- <input type="checkbox" id="bc-screenPreferCurrentTab-create" style="display:none">-- <select id="bc-screenSystemAudio-create" style="display:none"><option value="include" selected>Include</option><option value="auto">Auto</option><option value="exclude">Exclude</option></select>-- <select id="bc-screenSelfBrowser-create" style="display:none"><option value="exclude" selected>Exclude</option><option value="include">Include</option></select>-- <select id="bc-screenSurfaceSwitching-create" style="display:none"><option value="include" selected>Allow</option><option value="exclude">Disallow</option></select>-- <select id="bc-screen-audio" style="display:none"><option value="default">Default</option></select>-- <select id="bc-screen-camera" style="display:none"><option value="default">Default</option></select>---- ${_wsPreflight(browserMode)}---- <button class="btn btn-primary btn-lg bc-ws-golive-btn"-- onclick="goLiveFromWorkspace()" id="bc-ws-golive-btn">-- <i class="fa-solid fa-tower-broadcast"></i> Go Live-- </button>-- <p class="bc-create-reassurance" style="margin-top:6px;font-size:0.82rem">-- <i class="fa-solid fa-circle-info"></i>-- Live at <strong>openvibe.live${channelPath(currentUser?.username || 'your-channel', ms.slug || ms.id)}</strong>-- </p>-- ` : `-- <!-- Auto-detect notice for non-browser methods -->-- <div class="bc-ws-autodetect">-- <div class="bc-ws-autodetect-pulse"></div>-- <div>-- <strong>Auto-detect enabled</strong><br>-- Your stream will go live automatically when ${method === 'rtmp' ? 'your streaming software connects via RTMP'-- : method === 'whip' ? 'your encoder connects via WHIP'-- : 'FFmpeg or your streaming tool sends data to the endpoint'}.-- Configure your software using the <strong>Endpoint</strong> tab, then just start streaming.-- </div>-- </div>-- <button class="btn btn-small btn-outline" onclick="_wsTabSwitch('endpoint')" style="width:100%">-- <i class="fa-solid fa-plug"></i> View Endpoint & Setup Instructions-- </button>-- `}-- </div>-- </div>---- <!-- ═══ TAB: Settings ═══ -->-- <div class="bc-ws-tab-panel" data-wstabpanel="settings">-- <div class="bc-ws-profile-section">--<!-- Picture-in-picture camera -->-- <details class="bc-ws-slot-settings" id="bc-ws-pip-details">-- <summary><i class="fa-solid fa-clone"></i> Picture-in-picture</summary>++ <details class="gl-fold" id="bc-ws-pip-details">++ <summary><i class="fa-solid fa-clone" aria-hidden="true"></i><span class="bc-sum-title">Picture-in-picture</span><small class="bc-sum-val" id="gl-pip-sum">None</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><p class="muted" style="font-size:0.82rem;margin-bottom:10px">Show up to three other streams over this one: your webcam on a slot of its own, a second@@@ -669,14 +654,14 @@ function _wsRenderPanel() {</details><!-- Restream Destinations -->-- <details class="bc-ws-slot-settings" id="bc-ws-restream-details" open>-- <summary><i class="fa-solid fa-tower-broadcast"></i> Restream Destinations</summary>++ <details class="gl-fold" id="bc-ws-restream-details">++ <summary><i class="fa-solid fa-share-nodes" aria-hidden="true"></i><span class="bc-sum-title">Restreams</span><small class="bc-sum-val" id="gl-rs-sum">…</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><p class="muted" style="font-size:0.82rem;margin-bottom:10px">Stream this slot to external platforms (Twitch, YouTube, Kick, etc.). Each slot can have its own destinations.</p><div id="bc-ws-restream-list" class="bc-ws-restream-list">-- <div class="bc-ws-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</div>++ <div class="bc-ws-loading">Loading your destinations…</div></div><button class="btn btn-small btn-outline" style="margin-top:8px" onclick="_wsAddRestreamDest()"><i class="fa-solid fa-plus"></i> Add Destination@@@ -685,8 +670,8 @@ function _wsRenderPanel() {</details><!-- Chat Overlay -->-- <details class="bc-ws-slot-settings">-- <summary><i class="fa-solid fa-closed-captioning"></i> Chat Overlay</summary>++ <details class="gl-fold">++ <summary><i class="fa-solid fa-closed-captioning" aria-hidden="true"></i><span class="bc-sum-title">Chat on stream</span><small class="bc-sum-val">An OBS browser source</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><p class="muted" style="font-size:0.82rem;margin-bottom:8px">Add this URL as a Browser Source in OBS to display chat on stream.@@@ -716,8 +701,8 @@ function _wsRenderPanel() {</div></details>-- <details class="bc-ws-slot-settings">-- <summary><i class="fa-solid fa-code"></i> Vibe Coding Feed</summary>++ <details class="gl-fold">++ <summary><i class="fa-solid fa-code" aria-hidden="true"></i><span class="bc-sum-title">Vibe coding feed</span><small class="bc-sum-val">Set up on openvibe.chat</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><p class="muted" style="font-size:0.82rem;margin-bottom:10px">Show what you are building while you stream: Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and viewers follow along in a card above this slot's chat. Feeds live on OpenVibe Chat: make one there, pick this slot, and paste its Claude Code hooks. Secrets are wiped before anyone sees a step.@@@ -727,8 +712,8 @@ function _wsRenderPanel() {</details><!-- PowerChat overlay relay (shown only when PowerChat is connected) -->-- <details class="bc-ws-slot-settings bc-ws-pc-only">-- <summary><i class="fa-solid fa-bolt"></i> PowerChat Overlay</summary>++ <details class="gl-fold bc-ws-pc-only">++ <summary><i class="fa-solid fa-bolt" aria-hidden="true"></i><span class="bc-sum-title">PowerChat overlay</span><small class="bc-sum-val" id="gl-pc-sum">${ms.slot_powerchat_relay !== 0 ? 'Relaying chat' : 'Not relaying'}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><div class="bc-ws-row"><div class="form-group" style="flex:1">@@@ -759,14 +744,14 @@ function _wsRenderPanel() {</details><!-- AI Chat Viewers (v3) — compact per-slot controls; full panel lives on the dashboard -->-- <details class="bc-ws-slot-settings" id="bc-ws-aiv-details">-- <summary><i class="fa-solid fa-robot"></i> AI Chat Viewers</summary>++ <details class="gl-fold" id="bc-ws-aiv-details">++ <summary><i class="fa-solid fa-robot" aria-hidden="true"></i><span class="bc-sum-title">AI chat viewers</span><small class="bc-sum-val">For this slot</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><div id="bc-ws-aiv" data-ms="${ms.id}"><span class="muted">Loading…</span></div></div></details><!-- VOD / Clips Settings -->-- <details class="bc-ws-slot-settings">-- <summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>++ <details class="gl-fold">++ <summary><i class="fa-solid fa-film" aria-hidden="true"></i><span class="bc-sum-title">Recording</span><small class="bc-sum-val" id="gl-rec-sum">${_glRecSummary(ms)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><div class="bc-ws-row"><div class="form-group" style="flex:1">@@@ -815,8 +800,8 @@ function _wsRenderPanel() {</details><!-- Weather Widget Settings -->-- <details class="bc-ws-slot-settings">-- <summary><i class="fa-solid fa-cloud-sun"></i> Weather Widget Settings</summary>++ <details class="gl-fold">++ <summary><i class="fa-solid fa-cloud-sun" aria-hidden="true"></i><span class="bc-sum-title">Weather on stream</span><small class="bc-sum-val" id="gl-weather-sum">${_glWeatherSummary(ms)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="bc-ws-slot-settings-inner"><div class="bc-ws-row"><div class="form-group" style="flex:2">@@@ -845,55 +830,31 @@ function _wsRenderPanel() {</div></details>-- </div>-- </div>-- <!-- ═══ TAB: Endpoint & Key ═══ -->-- <div class="bc-ws-tab-panel" data-wstabpanel="endpoint">-- <div class="bc-ws-endpoint-section">-- <div class="bc-ws-endpoint-hd">-- <h3><i class="fa-solid fa-plug"></i> Stream Endpoint & Key</h3>-- </div>-- <p class="bc-ws-endpoint-note">-- This key belongs to <strong>${esc(ms.title || 'this stream slot')}</strong>.-- Each slot has its own stable key. Regenerating invalidates the current key immediately.-- </p>-- <div class="bc-ws-key-row">-- <div class="bc-ws-key-display">-- <input type="password" id="bc-ws-stream-key" class="form-input form-input-sm bc-ws-key-input"-- value="${esc(streamKey)}" readonly autocomplete="off" spellcheck="false"-- aria-label="Stream key">-- <button class="btn btn-small btn-ghost bc-ws-key-toggle"-- onclick="_wsToggleKeyVisibility()" title="Show / hide key">-- <i class="fa-solid fa-eye" id="bc-ws-key-eye"></i>-- </button>-- <button class="btn btn-small btn-ghost"-- onclick="_wsCopyStreamKey()" title="Copy stream key">-- <i class="fa-solid fa-copy"></i>-- </button>++ ${isBrowser ? `++ <details class="gl-fold" id="gl-endpoint">++ <summary><i class="fa-solid fa-plug" aria-hidden="true"></i><span class="bc-sum-title">Stream from an app instead</span><small class="bc-sum-val">OBS, WHIP or FFmpeg, with your key</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>++ <div class="gl-fold-body">${endpoint}</div>++ </details>` : ''}++++ <details class="gl-fold" id="gl-history">++ <summary><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i><span class="bc-sum-title">Past streams</span><small class="bc-sum-val" id="gl-hist-sum">…</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>++ <div class="gl-fold-body">++ <div id="bc-ws-history-list" class="bc-ws-history-list">++ <p class="muted" style="padding:4px 0">Loading your past streams…</p></div>-- <button class="btn btn-small btn-outline bc-ws-regen-btn"-- onclick="_wsRegenerateKey(${ms.id})">-- <i class="fa-solid fa-arrows-rotate"></i> Regen Key-- </button></div>-- <div id="bc-ws-method-endpoint">${_wsRenderMethodEndpoint(method, streamKey, ms.id)}</div>-- </div>++ </details></div>---- <!-- ═══ TAB: History ═══ -->-- <div class="bc-ws-tab-panel" data-wstabpanel="history">-- <div class="bc-ws-profile-section">-- <h3><i class="fa-solid fa-clock-rotate-left"></i> Recent Sessions</h3>-- <div id="bc-ws-history-list" class="bc-ws-history-list">-- <p class="muted" style="padding:12px 0"><i class="fa-solid fa-spinner fa-spin"></i> Loading\u2026</p>-- </div>-- </div></div>++++ <!-- Hidden element required by createNewStream() -->++ <select id="bc-managed-stream" hidden>++ <option value="${ms.id}" selected>${esc(ms.title || '')}</option>++ </select>`;-- // Restore active tab-- _wsTabSwitch(_wsState.activeTab || 'profile');++ _glSyncSummaries();// Async post-render: check device permissions// Corner/size selects are derived from the stored {x,y,w} fractions.@@@ -916,25 +877,132 @@ function _wsRenderPanel() {try { const aiv = document.getElementById('bc-ws-aiv'); if (aiv && window.aivRenderSlotSection) window.aivRenderSlotSection(aiv, aiv.dataset.ms); } catch { /* */ }}++/* ── The setup's one screen: summaries and chips (broadcast-workspace.js _wsRenderPanel) ── */++++/** The icon a slot is known by: the camera, the microphone or the screen in a browser; the encoder otherwise. */++function _glMethodIcon(method, browserMode) {++ if (method === 'rtmp') return 'server';++ if (method === 'whip') return 'satellite-dish';++ if (method === 'cli' || method === 'jsmpeg') return 'terminal';++ const m = _WS_BROWSER_MODES.find((x) => x.id === (browserMode || 'camera'));++ return m ? m.icon : 'video';++}++++function _glHowSummary(method, browserMode) {++ if (method !== 'browser' && method !== 'webrtc') return _wsMethodLabel(method);++ const m = _WS_BROWSER_MODES.find((x) => x.id === (browserMode || 'camera'));++ return `This browser · ${m ? m.label : 'Camera & Mic'}`;++}++++function _glDetailsSummary(ms) {++ const desc = (document.getElementById('bc-description')?.value ?? ms.description ?? '').trim();++ const slug = (document.getElementById('bc-slug')?.value ?? ms.slug ?? '').trim();++ return `${desc ? 'Description set' : 'No description'} · ${slug ? '/' + esc(slug) : 'no short link'}`;++}++++function _glRecSummary(ms) {++ const vis = { public: 'public', unlisted: 'unlisted', private: 'private' }[ms.default_vod_visibility || 'public'] || 'public';++ if (ms.slot_vod_recording_enabled === 0) return ms.slot_clip_recording_enabled === 0 ? 'Off' : 'Clips only';++ return `VODs ${vis}${ms.slot_clip_recording_enabled === 0 ? ' · no clips' : ' · clips on'}`;++}++++function _glWeatherSummary(ms) {++ if (ms.weather_detail === 'off') return 'Off';++ return ms.weather_zip ? esc(ms.weather_zip) : 'No place set';++}++++/** Every summary from what the fields hold now, so a change shows in its fold's line at once. */++function _glSyncSummaries() {++ const ms = _wsState.selectedMs;++ if (!ms) return;++ const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };++ set('gl-details-sum', _glDetailsSummary(ms));++ const method = ms.streaming_method || 'browser', mode = ms.browser_mode || _wsState.profile?.browserMode || 'camera';++ set('gl-how-sum', esc(_glHowSummary(method, mode)));++ const howIcon = document.querySelector('#gl-how > summary > i:first-child');++ if (howIcon) howIcon.className = `fa-solid fa-${_glMethodIcon(method, mode)}`;++ set('gl-rec-sum', _glRecSummary(ms));++ set('gl-weather-sum', _glWeatherSummary(ms));++ const pc = document.getElementById('bc-ws-powerchat-relay');++ if (pc) set('gl-pc-sum', pc.checked ? 'Relaying chat' : 'Not relaying');++ const nsfw = document.getElementById('bc-nsfw');++ if (nsfw) set('gl-mature-v', nsfw.checked ? 'on' : 'off');++ const slug = (document.getElementById('bc-slug')?.value ?? ms.slug ?? '').trim().toLowerCase();++ const user = (typeof currentUser !== 'undefined' && currentUser && currentUser.username) || '';++ if (user && typeof channelPath === 'function') set('gl-link', esc(`openvibe.live${channelPath(user, slug || ms.id)}`));++}++++/** Restreams in the chip and its fold: the names of those that start with the stream. */++function _glSyncRestreams() {++ const on = (_wsRestreamDests || []).filter((d) => d.enabled !== 0 && d.enabled !== false)++ .map((d) => (_wsRestreamPlatforms[d.platform] || {}).name || d.name || d.platform);++ if (_wsSlotRsIntegration && _wsSlotRsIntegration.enabled) on.push('RobotStreamer');++ const chip = document.getElementById('gl-rs-v');++ const sum = document.getElementById('gl-rs-sum');++ const names = on.length > 2 ? `${on.slice(0, 2).join(', ')} +${on.length - 2}` : on.join(', ');++ if (chip) chip.textContent = on.length ? names : 'none';++ if (sum) sum.textContent = on.length ? `${on.length} on · ${names}` : 'None yet';++}++++/** ✨ Suggest: three titles for the next stream (server/streaming/title-ideas.js); a tap puts one in the title. */++async function _glSuggestTitles(btn) {++ const ms = _wsState.selectedMs;++ const box = document.getElementById('gl-ideas');++ if (!ms || !box || btn.getAttribute('aria-busy') === 'true') return;++ const busy = window.OVMark && typeof OVMark.busy === 'function' ? OVMark.busy(btn) : null;++ btn.setAttribute('aria-busy', 'true');++ const now = new Date();++ const weekday = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getDay()];++ try {++ const r = await api(`/streams/managed/${ms.id}/title-ideas`, { method: 'POST', body: { weekday, hour: now.getHours(), title: document.getElementById('bc-title')?.value || '' } });++ const titles = (r && Array.isArray(r.titles)) ? r.titles : [];++ box.replaceChildren(...titles.map((t) => {++ const b = document.createElement('button');++ b.type = 'button'; b.className = 'gl-idea'; b.textContent = t;++ b.onclick = () => {++ const input = document.getElementById('bc-title');++ if (input) { input.value = t; input.focus(); }++ box.hidden = true;++ _wsMarkDirty();++ };++ return b;++ }));++ box.hidden = !titles.length;++ if (busy) busy.done(titles.length ? 'Ideas' : 'None');++ } catch (err) {++ box.replaceChildren(Object.assign(document.createElement('p'), { className: 'gl-ideas-note', textContent: (err && err.message) || 'No ideas right now. Try again in a minute.' }));++ box.hidden = false;++ if (busy) busy.fail('Try again');++ } finally {++ btn.removeAttribute('aria-busy');++ }++}++++function _glOpenFold(id) {++ const d = document.getElementById(id);++ if (!d) return;++ d.open = true;++ d.scrollIntoView({ block: 'start', behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });++}++++function _glCopyLink() {++ const t = document.getElementById('gl-link')?.textContent?.trim();++ if (!t) return;++ _wsCopyText(`https://${t.replace(/^https?:\/\//, '')}`, 'Link copied');++}++/* ── Tab switching ───────────────────────────────────────────── */function _wsTabSwitch(tabId) {-- _wsState.activeTab = tabId;-- document.querySelectorAll('.bc-ws-tab').forEach(t =>-- t.classList.toggle('active', t.dataset.wstab === tabId)-- );-- document.querySelectorAll('.bc-ws-tab-panel').forEach(p =>-- p.classList.toggle('active', p.dataset.wstabpanel === tabId)-- );-- // Load history lazily when switching to history tab-- if (tabId === 'history' && _wsState.selectedId) {-- _wsLoadHistory(_wsState.selectedId);-- }++ const fold = { endpoint: 'gl-endpoint', history: 'gl-history', settings: 'bc-ws-restream-details' }[tabId];++ if (fold) _glOpenFold(fold);}/* ── Floating save bar (Discord-style) ───────────────────────── */function _wsEnsureSaveBar() {++ return; // no save bar: changes save as they are made++ // eslint-disable-next-line no-unreachableif (document.getElementById('bc-ws-save-bar')) return;const bar = document.createElement('div');bar.className = 'bc-ws-save-bar hidden';@@@ -1696,13 +1764,26 @@ async function _wsRegenerateKey(managedStreamId) {/* ── Dirty state / Manual Save ───────────────────────────────── */++let _wsAutoSaveTimer = null;function _wsMarkDirty() {_wsState.dirty = true;-- _wsUpdateSaveButton();++ _wsState.rev = (_wsState.rev || 0) + 1;_wsSyncQualitySummary();++ _glSyncSummaries();++ clearTimeout(_wsAutoSaveTimer);++ _wsAutoSaveTimer = setTimeout(() => { _wsSaveAll(); }, 900);++}++++/** Save now what is waiting (before switching slots or going live). */++async function _wsFlushSave() {++ clearTimeout(_wsAutoSaveTimer);++ if (_wsState.savePromise) await _wsState.savePromise;++ if (_wsState.dirty) await _wsSaveAll();}function _wsUpdateSaveButton() {++ return; // saving is automatic now (_wsMarkDirty); kept for older callers++ // eslint-disable-next-line no-unreachableconst bar = document.getElementById('bc-ws-save-bar');const btn = document.getElementById('bc-ws-save-btn');const status = document.getElementById('bc-ws-save-status');@@@ -1731,21 +1812,27 @@ function _wsUpdateSaveButton() {function _wsShowSaveStatus(msg, cls) {const el = document.getElementById('bc-ws-save-status');-- const bar = document.getElementById('bc-ws-save-bar');-- if (el) {++ if (!el) return;++ el.className = 'bc-ws-save-status' + (cls ? ' ' + cls : '');++ if (cls === 'bc-ws-save-error') {++ el.replaceChildren(document.createTextNode(msg + ' '));++ const again = document.createElement('button');++ again.type = 'button'; again.className = 'gl-retry'; again.textContent = 'Try again';++ again.onclick = () => { _wsState.dirty = true; _wsSaveAll(); };++ el.append(again);++ } else {el.textContent = msg;-- el.className = 'bc-ws-save-status' + (cls ? ' ' + cls : '');}-- // Show the bar briefly for save/error feedback even when not dirty-- if (bar && msg) bar.style.display = '';}/** Save all changes (structured fields + profile blob) */async function _wsSaveAll() {-- if (!_wsState.selectedId || _wsState.saving || !_wsState.dirty) return;++ if (!_wsState.selectedId || !_wsState.dirty) return;++ if (_wsState.saving) { _wsState.saveAgain = true; return; }_wsState.saving = true;-- const btn = document.getElementById('bc-ws-save-btn');-- if (btn) btn.disabled = true;++ const rev = _wsState.rev || 0;++ let settle;++ _wsState.savePromise = new Promise((resolve) => { settle = resolve; });_wsShowSaveStatus('Saving\u2026', 'bc-ws-saving');try {@@@ -1809,20 +1896,28 @@ async function _wsSaveAll() {if (idx !== -1) _wsState.managedStreams[idx] = { ..._wsState.managedStreams[idx], ...updated.managed_stream };}_wsState.profile = profileBlob;-- _wsState.dirty = false;++ // A change made while this was saving stays to save next.++ if ((_wsState.rev || 0) === rev) _wsState.dirty = false;_wsRenderSidebar();-- _wsShowSaveStatus('Saved \u2713', 'bc-ws-saved');-- setTimeout(() => { if (!_wsState.dirty) _wsShowSaveStatus(''); }, 2500);++ _glSyncSummaries();++ _wsShowSaveStatus('Saved', 'bc-ws-saved');++ setTimeout(() => { if (!_wsState.dirty && !_wsState.saving) _wsShowSaveStatus(''); }, 2500);// Update sidebar method displayconst methodToProtocol = { browser: 'webrtc', whip: 'webrtc', cli: 'jsmpeg', rtmp: 'rtmp' };broadcastState.selectedMethod = methodToProtocol[streamFields.streaming_method] || streamFields.protocol;} catch (err) {-- _wsShowSaveStatus('Save failed: ' + (err?.message || 'unknown error'), 'bc-ws-save-error');++ _wsShowSaveStatus('Not saved: ' + (err?.message || 'the server did not answer') + '.', 'bc-ws-save-error');} finally {_wsState.saving = false;-- _wsUpdateSaveButton();++ _wsState.savePromise = null;++ settle();++ if (_wsState.dirty && (_wsState.saveAgain || (_wsState.rev || 0) !== rev)) {++ _wsState.saveAgain = false;++ clearTimeout(_wsAutoSaveTimer);++ _wsAutoSaveTimer = setTimeout(() => { _wsSaveAll(); }, 900);++ }}}@@@ -2156,6 +2251,8 @@ function _wsPipRender() {const box = document.getElementById('bc-ws-pip-list');const add = document.getElementById('bc-ws-pip-add');if (!st || !box || !add) return;++ const ps = document.getElementById('gl-pip-sum');++ if (ps) ps.textContent = st.list.length ? `${st.list.length} over this stream` : 'None';const mine = (_wsState.managedStreams || []);const ownIds = new Set(mine.map((m) => Number(m.id)));box.replaceChildren(...st.list.map((p, i) => {@@@ -2234,12 +2331,8 @@ async function goLiveFromWorkspace() {return;}-- // Prompt to save if dirty-- if (_wsState.dirty) {-- const action = await _wsConfirmUnsaved('You have unsaved changes. Save before going live?');-- if (action === 'cancel') return;-- if (action === 'save') await _wsSaveAll();-- }++ // What was just typed (the title above all) is saved before the stream starts.++ await _wsFlushSave();const p = _wsState.profile;const ms = _wsState.selectedMs;@@@ -2346,12 +2439,16 @@ async function _wsLoadHistory(managedStreamId) {_wsRenderHistory(_wsState.history);} catch {list.innerHTML = '<p class="muted">Could not load session history.</p>';++ const hs = document.getElementById('gl-hist-sum');++ if (hs) hs.textContent = 'Could not load';}}function _wsRenderHistory(sessions) {const list = document.getElementById('bc-ws-history-list');if (!list) return;++ const hs = document.getElementById('gl-hist-sum');++ if (hs) hs.textContent = sessions.length ? `${sessions.length} recent` : 'None yet';if (!sessions.length) {list.innerHTML = '<p class="muted" style="padding:8px 0">No past sessions yet. Go live to start your first session.</p>';@@@ -2509,9 +2606,10 @@ function _wsRenderRestreamList(container) {if (!container) return;const rsCard = _wsRenderSlotRsCard();++ _glSyncRestreams();if (_wsRestreamDests.length === 0 && !rsCard) {-- container.innerHTML = '<p class="muted" style="font-size:0.82rem">No restream destinations configured for this stream slot.</p>';++ container.innerHTML = '<p class="muted" style="font-size:0.82rem">Nowhere else yet. Add Twitch, YouTube, Kick or any RTMP address, and this slot goes there too each time you go live.</p>';return;}@@@ -1505,6 +1505,9 @@ router.get('/channel/:username/resolve/:ref', optionalAuth, async (req, res) =>// ── Managed Stream CRUD ──────────────────────────────────────++// ✨ Suggest on the go-live card: three titles for the slot's next stream (title-ideas.js).++require('./title-ideas').mount(router, { requireAuth, db });++// Get past stream sessions for a managed stream (workspace history panel)router.get('/managed/:managedStreamId/history', requireAuth, async (req, res) => {const managedStreamId = parseInt(req.params.managedStreamId);nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * ✨ Suggest, on the go-live card (golive-setup redesign, 2026-11-02): three titles for a slot's next stream, written++ * from the titles and categories of its last streams and the streamer's own day and hour (the browser sends them; the++ * server does not know the streamer's time zone). One short AI call, attributed to the streamer (Live's per-streamer++ * AI budget), and a few a minute at most. The streamer picks one or ignores them; nothing is stored.++ *++ * POST /api/streams/managed/:managedStreamId/title-ideas { weekday?, hour? } → { titles: [..3] }++ */++++const MAX_TITLES = 3;++const TITLE_MAX = 80;++const WINDOW_MS = 10 * 60 * 1000;++const PER_WINDOW = 12;++const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];++++const SYSTEM = [++ 'You name live streams. Write titles a streamer would choose for their next stream on this slot.',++ 'Keep their own way of naming streams: their words, their length, their language.',++ 'Each title is plain words, under 60 characters: no hashtags, no emoji, no quotation marks, no "LIVE", no clickbait',++ 'and no made-up facts (no guests, numbers or events the past titles do not show).',++ 'The three titles differ from each other and from the current title.',++].join(' ');++++const _uses = new Map(); // userId → timestamps in the window++++/** True when this person may ask again now (and counts the ask). */++function allow(userId, now = Date.now()) {++ const list = (_uses.get(userId) || []).filter((t) => now - t < WINDOW_MS);++ if (list.length >= PER_WINDOW) { _uses.set(userId, list); return false; }++ list.push(now);++ _uses.set(userId, list);++ return true;++}++++function partOfDay(hour) {++ if (!Number.isInteger(hour) || hour < 0 || hour > 23) return null;++ if (hour < 5) return 'late at night';++ if (hour < 12) return 'in the morning';++ if (hour < 17) return 'in the afternoon';++ if (hour < 21) return 'in the evening';++ return 'at night';++}++++/** A title as a streamer would type it: one line, no wrapping quotes or hashtags, not too long. */++function clean(t) {++ let s = String(t == null ? '' : t).replace(/\s+/g, ' ').trim();++ s = s.replace(/^\d+[.)]\s*/, '').replace(/\s#\w+/g, '').trim().replace(/^["'“”‘’«»]+|["'“”‘’«»]+$/g, '').trim();++ if (s.length > TITLE_MAX) s = s.slice(0, TITLE_MAX).replace(/\s+\S*$/, '').trim();++ return s;++}++++function promptFor({ slot, sessions, weekday, hour }) {++ const recent = sessions.slice(0, 8).map((s) => {++ const when = s.started_at ? new Date(s.started_at) : null;++ const day = when && !Number.isNaN(when.getTime()) ? DAYS[(when.getUTCDay() + 6) % 7] : '';++ return `- ${clean(s.title) || '(untitled)'}${s.category ? ` (${String(s.category).slice(0, 40)})` : ''}${day ? `, a ${day}` : ''}`;++ });++ const when = [DAYS.includes(weekday) ? `It is ${weekday}` : '', partOfDay(hour) || ''].filter(Boolean).join(' ');++ return [++ `Slot: ${clean(slot.title) || '(untitled)'}${slot.description ? ` — ${String(slot.description).replace(/\s+/g, ' ').slice(0, 200)}` : ''}`,++ recent.length ? `Their last streams on this slot, newest first:\n${recent.join('\n')}` : 'No past streams on this slot yet.',++ when ? `${when} for the streamer.` : '',++ `Current title: ${clean(slot.title) || '(none)'}`,++ `Write ${MAX_TITLES} titles for the next stream.`,++ ].filter(Boolean).join('\n');++}++++/**++ * The titles, or [] when the AI has nothing usable. deps: { db, llm, wordFilter } for tests.++ */++async function ideas({ slot, userId, weekday, hour }, deps = {}) {++ const db = deps.db || require('../db/database');++ const llm = deps.llm || require('../ai/llm');++ let sessions = [];++ try { sessions = await db.getStreamHistoryByManagedStream(slot.id, userId, 8); } catch { sessions = []; }++ const r = await llm.complete({++ role: 'chat', system: SYSTEM, user: promptFor({ slot, sessions, weekday, hour }),++ json: { name: 'titles', strict: true, schema: { type: 'object', additionalProperties: false, required: ['titles'], properties: { titles: { type: 'array', items: { type: 'string' } } } } },++ maxTokens: 180, temperature: 0.8, kind: 'golive_title_ideas', source: 'golive-suggest', ownerUserId: userId, timeoutMs: 20000,++ }).catch(() => null);++ if (!r) return [];++ let raw = r.json && Array.isArray(r.json.titles) ? r.json.titles : null;++ if (!raw) {++ try { const j = JSON.parse(r.text); raw = Array.isArray(j.titles) ? j.titles : null; } catch { raw = String(r.text || '').split('\n'); }++ }++ let filter = deps.wordFilter;++ if (!filter) { try { filter = require('../chat/word-filter'); } catch { filter = null; } }++ const seen = new Set([clean(slot.title).toLowerCase()]);++ const out = [];++ for (const t of raw || []) {++ const c = clean(t);++ if (c.length < 3 || seen.has(c.toLowerCase())) continue;++ if (filter && filter.check && !filter.check(c).safe) continue;++ seen.add(c.toLowerCase());++ out.push(c);++ if (out.length === MAX_TITLES) break;++ }++ return out;++}++++function mount(router, { requireAuth, db }) {++ router.post('/managed/:managedStreamId/title-ideas', requireAuth, async (req, res) => {++ const id = parseInt(req.params.managedStreamId, 10);++ if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid ID' });++ try {++ const slot = await db.getManagedStreamById(id);++ if (!slot) return res.status(404).json({ error: 'Managed stream not found' });++ if (slot.user_id !== req.user.id) return res.status(403).json({ error: 'Not your managed stream' });++ if (!allow(req.user.id)) return res.status(429).json({ error: 'That is a lot of ideas: try again in a few minutes.' });++ const body = req.body || {};++ const hour = Number.isInteger(body.hour) ? body.hour : null;++ const weekday = typeof body.weekday === 'string' ? body.weekday : null;++ // The title being typed counts as the current one, so the ideas differ from it.++ const typed = typeof body.title === 'string' ? body.title.slice(0, 200) : null;++ const titles = await ideas({ slot: { ...slot, title: typed != null ? typed : slot.title }, userId: req.user.id, weekday, hour });++ res.set('Cache-Control', 'no-store');++ if (!titles.length) return res.status(503).json({ error: 'No ideas right now. Try again in a minute.' });++ res.json({ titles });++ } catch (err) {++ console.error('[TitleIdeas]', err.message);++ res.status(500).json({ error: 'No ideas right now. Try again in a minute.' });++ }++ });++}++++module.exports = { ideas, mount, clean, promptFor, partOfDay, allow, _uses, MAX_TITLES };nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++++/**++ * The go-live setup is one screen (owner, 2026-10-31: "this layout ain't too great… more intelligent… more modular"):++ * the slots are pills, the ready card comes first (title, chips, devices, what viewers get, Go live), and every other++ * setting is a fold whose line says what it holds. Nothing the old Stream / Settings / Endpoint / History tabs had is++ * lost: every field they rendered is still rendered, by the same id. Changes save as they are made.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ws = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-workspace.js'), 'utf8');++const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'broadcast.html'), 'utf8');++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'broadcast.css'), 'utf8');++const start = ws.indexOf('function _wsRenderPanel()');++const panel = ws.slice(start, ws.indexOf('\n}\n', start));++++// Every field of the old tabs, by id: Stream (title … quality), Go live (devices, button), Settings (PiP, restreams,++// overlay, PowerChat, AI viewers, recording, weather), Endpoint (key, method endpoint) and History.++const FIELDS = [++ 'bc-title', 'bc-description', 'bc-nsfw', 'bc-slug',++ 'bc-ws-method-picker', 'bc-ws-browser-mode-picker', 'bc-ws-mic-image',++ 'bc-ws-screen-mic', 'bc-ws-screen-mic-select', 'bc-ws-screen-sysaudio', 'bc-ws-screen-cam', 'bc-ws-screen-cam-select',++ 'bc-ws-screen-cam-mode', 'bc-ws-screen-pip-pos', 'bc-ws-screen-pip-size',++ 'bc-ws-screen-resolution', 'bc-ws-screen-fps', 'bc-ws-screen-bitrate',++ 'bc-ws-resolution', 'bc-ws-fps', 'bc-ws-bitrate', 'bc-ws-battery-saver',++ 'bc-create-camera', 'bc-create-audio', 'bc-ws-golive-btn', 'bc-ws-golive-section',++ 'bc-ws-pip-list', 'bc-ws-pip-add', 'bc-ws-restream-list', 'bc-ws-overlay-url',++ 'bc-ws-powerchat-relay', 'bc-ws-powerchat-rs-views', 'bc-ws-aiv',++ 'bc-ws-vod-recording', 'bc-ws-clip-recording', 'bc-ws-clip-notify', 'bc-ws-vod-visibility', 'bc-ws-clip-visibility',++ 'bc-ws-weather-zip', 'bc-ws-weather-detail', 'bc-ws-weather-location',++ 'bc-ws-stream-key', 'bc-ws-method-endpoint', 'bc-ws-history-list', 'bc-managed-stream',++];++for (const id of FIELDS) assert.ok(panel.includes(`id="${id}"`), `the setup still renders #${id}`);++// The endpoint block is one template, placed in the card (an encoder's slot) or in a fold (a browser slot): never twice.++assert.strictEqual((panel.match(/id="bc-ws-stream-key"/g) || []).length, 1, 'the key field is written once');++assert.ok(/const endpoint = `/.test(panel) && /\$\{endpoint\}/.test(panel), 'the endpoint goes where the method needs it');++++// One screen: no tabs; the card first, then the folds.++assert.ok(!panel.includes('data-wstab') && !panel.includes('bc-ws-tab-panel'), 'no tabs');++const card = panel.indexOf('<section class="gl-card');++const folds = panel.indexOf('<div class="gl-folds">');++assert.ok(card > 0 && folds > card, 'the ready card, then the folds');++assert.ok(panel.indexOf('id="bc-title"') > card && panel.indexOf('id="bc-title"') < folds, 'the title is in the card');++assert.ok(panel.indexOf('${_wsPreflight(browserMode)}') > card && panel.indexOf('${_wsPreflight(browserMode)}') < folds, 'what viewers get is in the card');++++// Every fold's line says what it holds (L25: a fold shows its count or summary).++const foldCount = (panel.match(/<details class="gl-fold/g) || []).length;++assert.ok(foldCount >= 12, `the settings are folds (${foldCount})`);++for (const m of panel.matchAll(/<details class="gl-fold[^>]*>\s*<summary>([\s\S]*?)<\/summary>/g)) {++ assert.ok(/class="bc-sum-val"/.test(m[1]), `a fold's line has a value: ${m[1].slice(0, 120)}`);++}++for (const id of ['gl-details-sum', 'gl-how-sum', 'gl-rs-sum', 'gl-pip-sum', 'gl-rec-sum', 'gl-weather-sum', 'gl-hist-sum']) {++ assert.ok(panel.includes(`id="${id}"`), `#${id} is in the panel`);++ assert.ok(ws.includes(`'${id}'`), `#${id} is kept current`);++}++++// The chips say what is set and act in place.++for (const chip of ['id="gl-mature-v"', 'id="gl-link"', 'id="gl-rs-v"', '_wsOpenPopoutChat()']) assert.ok(panel.includes(chip), chip);++++// Saving as you type: no save bar and no "unsaved changes" question; switching slots or going live saves first.++assert.ok(/function _wsMarkDirty\(\)[\s\S]{0,400}_wsAutoSaveTimer = setTimeout/.test(ws), 'a change saves after a short pause');++const select = ws.slice(ws.indexOf('async function wsSelectStream('), ws.indexOf('async function _wsSelectStream('));++assert.ok(select.includes('await _wsFlushSave()') && !select.includes('_wsConfirmUnsaved'), 'switching slots saves, never asks');++const golive = ws.slice(ws.indexOf('async function goLiveFromWorkspace('), ws.indexOf('function updateWorkspaceLiveStatus('));++assert.ok(golive.includes('await _wsFlushSave()') && !golive.includes('_wsConfirmUnsaved'), 'going live saves first, never asks');++assert.ok(/if \(\(_wsState\.rev \|\| 0\) === rev\) _wsState\.dirty = false/.test(ws), 'a change made while saving is not lost');++++// The slots are pills, with New slot at the end; nothing spins while the page loads.++assert.ok(/class="gl-slot\$\{isSelected/.test(ws) && ws.includes('gl-slot--add'), 'slot pills');++assert.ok(frag.includes('class="bc-ws-sidebar gl-slots"') && !frag.includes('bc-ws-sidebar-hd'), 'the fragment has the pill row');++assert.ok(!panel.includes('fa-spin') && !frag.slice(frag.indexOf('bc-ws-list'), frag.indexOf('bc-ws-main')).includes('fa-spin'), 'no spinning ring');++++// Wide screens: two columns, the card sticky; phones: 44 px targets.++assert.ok(/@container bcws \(min-width: 900px\) \{\s*\.gl \{ grid-template-columns/.test(css), 'two columns on a wide screen');++assert.ok(/@media \(pointer: coarse\) \{ \.gl-chip \{ min-height: 44px; \} \}/.test(css), 'chips are thumb-sized on touch');++++console.log('golive-setup: all passed');nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++++/**++ * ✨ Suggest (server/streaming/title-ideas.js): three clean titles from the slot's last streams and the streamer's own++ * hour, never the current one again, never a filtered word; a short AI call attributed to the streamer; a few a minute.++ */++const assert = require('assert');++const ti = require('../server/streaming/title-ideas');++++(async () => {++ const slot = { id: 11, title: 'Phone Home Stream', description: 'Walking around town', user_id: 1 };++ const sessions = [++ { title: 'Night walk downtown', category: 'IRL', started_at: '2026-10-31T23:00:00Z' },++ { title: 'Market day', category: 'IRL', started_at: '2026-10-30T15:00:00Z' },++ ];++ const db = { async getStreamHistoryByManagedStream(id, userId, limit) { assert.deepStrictEqual([id, userId, limit], [11, 1, 8]); return sessions; } };++ let call = null;++ const llm = { async complete(o) { call = o; return { json: { titles: ['"Night walk by the river"', 'Phone Home Stream', '1. Sunday market loop #irl', 'badword here', 'Coffee and a long walk', 'One more'] } }; } };++ const wordFilter = { check: (t) => ({ safe: !/badword/.test(t) }) };++++ const titles = await ti.ideas({ slot, userId: 1, weekday: 'Sunday', hour: 21 }, { db, llm, wordFilter });++ assert.deepStrictEqual(titles, ['Night walk by the river', 'Sunday market loop', 'Coffee and a long walk'], 'cleaned, the current title and a filtered one left out, three at most');++ assert.strictEqual(call.ownerUserId, 1, 'the spend is the streamer\'s');++ assert.strictEqual(call.kind, 'golive_title_ideas');++ assert.ok(call.maxTokens <= 200, 'a short call');++ assert.ok(call.user.includes('- Night walk downtown (IRL)'), 'the last streams are in the prompt');++ assert.ok(call.user.includes('It is Sunday at night for the streamer.'), 'the streamer\'s own day and hour');++ assert.ok(call.json && call.json.schema.properties.titles, 'a JSON answer');++++ // A plain-text answer still works; nothing usable is [].++ const textLlm = { async complete() { return { text: 'Walk one\nWalk two' }; } };++ assert.deepStrictEqual(await ti.ideas({ slot, userId: 1 }, { db, llm: textLlm, wordFilter }), ['Walk one', 'Walk two']);++ assert.deepStrictEqual(await ti.ideas({ slot, userId: 1 }, { db, llm: { async complete() { return null; } }, wordFilter }), []);++++ assert.strictEqual(ti.clean(' "A very long title" #tag '), 'A very long title');++ assert.strictEqual(ti.partOfDay(8), 'in the morning');++ assert.strictEqual(ti.partOfDay(99), null);++++ // A few a minute: the 13th ask in ten minutes waits, and the window frees.++ ti._uses.clear();++ const asks = Array.from({ length: 12 }, (_, i) => ti.allow(7, 1000 + i));++ assert.ok(asks.every(Boolean), 'twelve asks pass');++ assert.ok(!ti.allow(7, 2000), 'the limit holds');++ assert.ok(ti.allow(7, 1000 + 11 * 60 * 1000), 'and frees after the window');++ console.log('golive-title-ideas: all passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -108,31 +108,19 @@ assert.ok(/muted: pip\.muted !== false/.test(pip),assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');console.log('OK I: geometry persists, hide is recoverable, audio defaults to muted, offline degrades gracefully');--// ── J: workspace placement — Settings owns the control profile and the PiP section ───++// ── J: workspace placement — the PiP section is its own fold on the one-screen setup ───{const ws = read('public/js/broadcast-workspace.js');-- const at = (name) => ws.indexOf(`data-wstabpanel="${name}"`);-- const [prof, settings, endpoint] = [at('profile'), at('settings'), at('endpoint')];-- assert.ok(prof > 0 && settings > prof && endpoint > settings, 'expected profile/settings/endpoint panels in order');-- const streamPanel = ws.slice(prof, settings);-- const settingsPanel = ws.slice(settings, endpoint);--// Live's robot controls are gone (robots are OpenVibe.Bot's): no control profile select anywhere.assert.ok(!ws.includes('id="bc-control-config"'), 'no control profile select in the workspace');-- assert.ok(settingsPanel.includes('id="bc-ws-pip-list"') && settingsPanel.includes('id="bc-ws-pip-add"'),-- 'the PIP list must render in the Settings tab');---- // It is its own collapsible section, matching the other Settings groups.-- for (const label of ['Picture-in-picture']) {-- // Anchor on the SUMMARY text: an HTML comment carries the same label just above-- // the <details>, and matching that would look in the wrong place entirely.-- const idx = settingsPanel.indexOf(`${label}</summary>`);-- assert.ok(idx > 0, `Settings must have a "${label}" collapsible section`);-- const before = settingsPanel.slice(Math.max(0, idx - 400), idx);-- assert.ok(before.includes('<details class="bc-ws-slot-settings" id="bc-ws-pip-details">'),-- `"${label}" must be a collapsible <details> section like the rest of Settings`);-- }-- console.log('OK J: the PiP camera is a collapsible section under Settings, and there is no control profile');++ // No tabs since the one-screen setup (2026-11-02): the PIP list is a fold of its own, with its count in its line.++ assert.ok(!ws.includes('data-wstabpanel='), 'no tab panels');++ const at = ws.indexOf('<details class="gl-fold" id="bc-ws-pip-details">');++ assert.ok(at > 0, 'Picture-in-picture is a fold');++ const fold = ws.slice(at, ws.indexOf('</details>', at));++ assert.ok(fold.includes('<span class="bc-sum-title">Picture-in-picture</span>') && fold.includes('id="gl-pip-sum"'), 'its line says how many');++ assert.ok(fold.includes('id="bc-ws-pip-list"') && fold.includes('id="bc-ws-pip-add"'), 'the PIP list renders in its fold');++ console.log('OK J: the PiP camera is its own fold, and there is no control profile');}// ── K: PiP starting geometry round-trips through corner/size ─────────────────────────
@@ -81,6 +81,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
@@ -81,6 +81,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/go-live-in-your-browser.md](docs/go-live-in-your-browser.md) — Going live from a browser with no OBS and no follower minimum (the headline feature: keep it on the home page, in /llms.txt and in search metadata)
- [docs/go-live-in-your-browser.md](docs/go-live-in-your-browser.md) — Going live from a browser with no OBS and no follower minimum (the headline feature: keep it on the home page, in /llms.txt and in search metadata)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
+- [docs/streams-api.md](docs/streams-api.md) — Stream slots API: everything the Go live screen does, as calls (slots, settings, key, title ideas, history, going live and ending)
- [docs/rundown.md](docs/rundown.md) — The Rundown (`/rundown`, was the Arena): what streamers talked about, written up from their speech — topics, takes, episodes, crossovers
- [docs/rundown.md](docs/rundown.md) — The Rundown (`/rundown`, was the Arena): what streamers talked about, written up from their speech — topics, takes, episodes, crossovers
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+# Stream slots API
+
+Everything the Go live screen does is an API call: list your slots, make one, change its title or settings, get its
+server and key, ask for title ideas, see its past streams, and end a stream. A phone app, a bot or a tool of your own
+goes live the same way the page does.
+
+A **slot** is one stream of yours. It holds the title, the link (`openvibe.live/@you/<slug>`), how it goes out (your
+browser, OBS over RTMP, WHIP or FFmpeg), its restreams and its recording settings. Each slot has its own stream key.
+
+## Signing in
+
+Send `Authorization: Bearer <token>` with every call.
+
+- The token is an OpenVibe.Network sign-in, or an API token made under Settings → API tokens (see
+ [API tokens](api-tokens.md)).
+- An API token needs the `read` scope for reads and the `stream` scope for changes.
+- `GET …/profile` answers with the slot's `stream_key`. A token that can read it can publish to the slot, so keep that
+ token as safe as the key itself.
+
+Every path below starts with `https://openvibe.live/api/streams`.
+
+In JavaScript, [`openvibe-sdk/live`](https://github.com/OpenVibers/OpenVibe.SDK) wraps these calls:
+`live.slots.list()`, `live.slots.create()`, `live.slots.update()`, `live.slots.profile()`, `live.slots.history()`,
+`live.slots.quality()`, `live.slots.titleIdeas()`, `live.goLive()`, `live.streams.end()` and the WHIP helpers.
+
+## Slots
+
+| Call | What it does |
+|------|--------------|
+| `GET /managed` | Your slots: `{ managed_streams: [{ id, title, slug, description, is_nsfw, streaming_method, browser_mode, … }] }` |
+| `POST /managed` | Make a slot: `{ title, description?, slug?, is_nsfw?, streaming_method? }` → `{ managed_stream }`. The number of slots per account is limited; the error says the limit. |
+| `PUT /managed/:id` | Change a slot. Send only what changes: `title`, `description`, `slug`, `is_nsfw`, `streaming_method` (`browser`, `rtmp`, `whip`, `cli`), `browser_mode` (`camera`, `camera_only`, `mic_only`, `screen`), `default_vod_visibility` and `default_clip_visibility` (`public`, `unlisted`, `private`), `slot_vod_recording_enabled`, `slot_clip_recording_enabled`, `slot_clip_notify_enabled` (`1` or `0`), `weather_zip` and `weather_detail`. |
+| `DELETE /managed/:id` | Delete a slot. A live slot cannot be deleted (409): end its stream first. |
+| `GET /managed/:id/profile` | All of a slot in one call: the slot, its quality settings (`broadcast_settings`), its restreams, its RTMP server (`rtmp_url`), its WHIP base (`whip_url_base`) and its `stream_key`. |
+| `PUT /broadcast-settings` | A slot's quality: `{ managed_stream_id, settings: { resolution: "1080", fps: "30", bitrate: 4500 } }` (the bitrate is in kbps). |
+| `POST /managed/:id/regenerate-key` | A new stream key for the slot. The old one stops working at once. |
+| `GET /managed/:id/history` | The slot's past streams, newest first: `{ sessions: [{ id, title, started_at, duration_seconds, peak_viewers, vod_id, … }] }` |
+| `POST /managed/:id/title-ideas` | Three title ideas for the next stream, written from the slot's last streams: `{ weekday?: "Sunday", hour?: 21, title?: "what is typed now" }` → `{ titles: ["…", "…", "…"] }`. A few calls a minute at most (429 says to wait). It uses your own AI allowance, and nothing is stored. |
+
+## Going live
+
+How a slot goes live depends on its `streaming_method`.
+
+- **Starting a session from a slot:** `POST /` with `{ managed_stream_id, title? }` returns `{ stream, endpoint }`.
+ This is what the Go live button does before your browser connects. An encoder that connects by itself starts one on
+ its own.
+
+- **WHIP:** `POST https://openvibe.live/whip/<slot id>` with the slot's key as the Bearer token and your SDP offer.
+ It works from any WHIP client, including a browser page of your own. The [WHIP guide](whip.md) has the whole
+ session: the endpoint, its lifecycle, errors and CORS.
+- **RTMP:** point OBS, Streamlabs or IRL Pro at the slot's `rtmp_url`, with its `stream_key` as the key. The slot goes
+ live when the app connects and ends when it stops. Nothing is pressed here.
+- **FFmpeg / CLI:** the Go live page's "Stream from an app instead" row shows the commands for the slot, with its key.
+ Most tools are simplest over RTMP or WHIP.
+- **Browser:** the Go live page itself, with your camera, microphone or screen. A program that wants to publish from a
+ browser uses WHIP.
+
+`GET /mine` lists your streams, newest first. A live one has `is_live: true`, and its `id` is the stream's.
+
+## Ending a stream
+
+- `DELETE /:streamId` ends a live stream. The recording is finished and kept, following the slot's recording
+ settings.
+- An encoder can also just stop. A WHIP client sends `DELETE` to the session URL its `POST` returned.
+- A stream whose broadcaster drops stays live for five minutes, so a phone that loses signal comes back to the same
+ stream.
+
+## Errors
+
+Errors come back as `{ "error": "…" }` in plain words:
+
+| Code | Meaning |
+|------|---------|
+| 400 | A field is not valid (the message says which). |
+| 401 | No sign-in, or it expired. |
+| 403 | Not your slot, or the token's scopes do not allow the call. |
+| 404 | No such slot or stream. |
+| 409 | That short link is taken on your account, or the slot is live and cannot be deleted. |
+| 429 | Too many calls: wait a little. |
@@ -3142,3 +3142,132 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
@@ -3142,3 +3142,132 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
.bcs-livecard .bcs-gl-link code { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; font-size: 1.05rem; }
.bcs-livecard .bcs-gl-link code { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; font-size: 1.05rem; }
.bcs-livecard .bcs-gl-copy { flex: 1; justify-content: center; height: 46px; }
.bcs-livecard .bcs-gl-copy { flex: 1; justify-content: center; height: 46px; }
}
}
+
+/* ── Go live setup, one screen (2026-11-02; the owner: "this layout ain't too great… more intelligent… more modular").
+ The slots are a row of pills. The ready card comes first: the title, chips for what is set, the devices, what viewers
+ will get and Go live (an encoder's slot: waiting, with its address and key). Every other setting is a fold whose line
+ says what it holds. On a wide screen: two columns, the card sticky on the left. Changes save as they are made. ── */
+.bc-workspace { flex-direction: column; align-items: stretch; gap: 12px; }
+.bc-ws-sidebar.gl-slots { width: auto; position: static; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; overflow: visible; }
+.gl-slots .bc-ws-list { display: flex; flex-direction: row; gap: 8px; overflow-x: auto; overflow-y: visible; padding: 2px 2px 6px; scrollbar-width: none; }
+.gl-slots .bc-ws-list::-webkit-scrollbar { display: none; }
+.gl-slot { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 40px; max-width: 240px; padding: 0 14px;
+ border-radius: 999px; cursor: pointer; font-size: 0.88rem; font-weight: 650; color: var(--text-secondary);
+ background: color-mix(in srgb, var(--bg-card) 92%, transparent); border: 1px solid var(--border);
+ transition: background-color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s); }
+.gl-slot i { font-size: 0.85rem; color: var(--text-muted); }
+.gl-slot:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
+.gl-slot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.gl-slot.is-on { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 14%, var(--bg-card)); border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
+.gl-slot.is-on i { color: var(--accent); }
+.gl-slot-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gl-slot-live { flex: none; padding: 2px 7px; border-radius: 999px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: var(--live-red, #e11d48); color: #fff; }
+.gl-slot--add { color: var(--accent); border-style: dashed; background: transparent; }
+.gl-slot--add i { color: var(--accent); }
+@media (pointer: coarse) { .gl-slot { min-height: 44px; } }
+
+.bc-workspace > .bc-ws-main { border: 0; border-radius: 0; background: none; box-shadow: none; }
+.gl { display: grid; gap: 12px; align-items: start; min-width: 0; }
+@container bcws (min-width: 900px) {
+ .gl { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 18px; }
+ .gl-card { position: sticky; top: 8px; }
+}
+
+.gl-card { min-width: 0; padding: 16px; border-radius: 18px; border: 1px solid var(--border);
+ background: color-mix(in srgb, var(--bg-card) 94%, transparent); box-shadow: 0 1px 0 color-mix(in srgb, var(--text-primary) 4%, transparent) inset; }
+.gl-title-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.gl-title { flex: 1 1 auto; min-width: 0; width: 100%; box-sizing: border-box; margin: -5px -8px; padding: 5px 8px; font: inherit; font-size: 1.4rem; font-weight: 800;
+ line-height: 1.25; letter-spacing: -0.01em; color: var(--text-primary); background: transparent; border: 1px solid transparent; border-radius: 10px;
+ transition: border-color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }
+.gl-title:hover { border-color: var(--border); }
+.gl-title:focus { outline: none; border-color: var(--accent); background: var(--bg-input, var(--bg-primary)); }
+.gl-title::placeholder { color: var(--text-muted); font-weight: 700; }
+.gl-title-row .bc-ws-save-status { flex: none; min-width: 0; font-size: 0.78rem; font-weight: 650; font-style: normal; text-align: right; color: var(--text-muted); white-space: nowrap; }
+.gl-title-row .bc-ws-save-status.bc-ws-saved::before { content: "\2713\00a0"; color: var(--success-text, var(--success, #16a34a)); }
+.gl-title-row .bc-ws-save-status.bc-ws-save-error { flex: 0 1 50%; white-space: normal; color: var(--danger-text, var(--danger)); }
+.gl-retry { font: inherit; font-weight: 700; color: var(--accent); background: none; border: 0; padding: 4px 2px; cursor: pointer; text-decoration: underline; }
+
+.gl-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 14px; min-width: 0; }
+.gl-chip { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; max-width: 100%; padding: 0 12px;
+ border-radius: 999px; cursor: pointer; font-size: 0.82rem; font-weight: 600; color: var(--text-secondary);
+ background: color-mix(in srgb, var(--text-primary) 5%, transparent); border: 1px solid var(--border);
+ transition: border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s), background-color var(--ov-dur-2, .15s); }
+.gl-chip i { flex: none; font-size: 0.8rem; color: var(--text-muted); }
+.gl-chip b { flex: none; font-weight: 750; color: var(--text-primary); }
+.gl-chip span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gl-chip:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
+.gl-chip:focus-visible, .gl-chip:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
+.gl-chip--mature input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
+.gl-chip--mature:has(input:checked) { color: var(--danger-text, var(--danger)); border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); background: color-mix(in srgb, var(--danger) 9%, transparent); }
+.gl-chip--mature:has(input:checked) i, .gl-chip--mature:has(input:checked) b { color: var(--danger-text, var(--danger)); }
+.gl-chip--link { flex: 0 1 auto; min-width: 0; }
+.gl-chip--link i { color: var(--accent); }
+@media (pointer: coarse) { .gl-chip { min-height: 44px; } }
+
+/* The go-live block sits in the card without a frame of its own; the room for the phone's chat bubble moves to the end. */
+.gl-card .bc-ws-golive-section { margin: 0; padding: 0; border: 0; }
+.gl-card .bc-ws-golive-section .bc-ws-golive-btn { margin-top: 12px; }
+@container bcws (min-width: 960px) { .gl-card .bc-ws-golive-section.is-browser > * { max-width: none; } }
+@media (max-width: 680px) { .gl-card .bc-ws-golive-section.is-browser { padding-bottom: 0; } .gl-folds { padding-bottom: 76px; } }
+
+/* An encoder's slot: waiting, in words, then its address and key. */
+.gl-wait { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 12px; border-radius: 14px;
+ background: color-mix(in srgb, var(--accent) 7%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); }
+.gl-wait b { display: block; font-size: 0.95rem; color: var(--text-primary); }
+.gl-wait small { display: block; margin-top: 2px; font-size: 0.8rem; line-height: 1.45; color: var(--text-secondary); }
+.gl-wait-dot { flex: none; width: 10px; height: 10px; margin-top: 6px; border-radius: 50%; background: var(--accent);
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); animation: gl-breathe 2.4s var(--ov-ease-in-out, ease-in-out) infinite; }
+@keyframes gl-breathe { 50% { opacity: 0.4; } }
+.gl-card .bc-ws-endpoint-section, .gl-fold .bc-ws-endpoint-section { margin: 0; padding: 0; border: 0; border-radius: 0; background: none; }
+
+/* The folds: one row each, what it holds on the right, opened in place. */
+.gl-folds { display: grid; gap: 8px; min-width: 0; align-content: start; }
+.gl-fold { min-width: 0; padding: 0; margin: 0; border-radius: 14px; border: 1px solid var(--border); background: color-mix(in srgb, var(--bg-card) 94%, transparent); overflow: hidden; }
+.gl-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 8px 14px; cursor: pointer; list-style: none; user-select: none;
+ font-size: 0.9rem; font-weight: 650; color: var(--text-primary); transition: background-color var(--ov-dur-2, .15s); }
+.gl-fold > summary::-webkit-details-marker { display: none; }
+.gl-fold > summary:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
+.gl-fold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.gl-fold > summary > i:first-child { flex: none; width: 18px; margin: 0; text-align: center; color: var(--accent); }
+.gl-fold > summary .bc-sum-title { flex: none; white-space: nowrap; }
+.gl-fold > summary .bc-sum-val { flex: 1 1 auto; min-width: 0; margin-left: auto; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gl-fold[open] > summary { border-bottom: 1px solid var(--border); }
+.gl-fold[open] > summary .bc-sum-chev { transform: rotate(180deg); color: var(--accent); }
+.gl-fold-body { padding: 12px 14px 14px; min-width: 0; }
+.gl-fold > .bc-ws-slot-settings-inner { padding: 12px 14px 14px; }
+.gl-fold-body > .form-group:last-of-type { margin-bottom: 6px; }
+.gl-danger-link { font: inherit; font-size: 0.84rem; font-weight: 650; color: var(--danger-text, var(--danger)); background: none; border: 0; padding: 8px 0 2px; cursor: pointer; }
+.gl-danger-link:hover { text-decoration: underline; }
+
+/* Opening a slot: three quiet dots, never a spinning ring. */
+.gl-loading { display: flex; justify-content: center; gap: 6px; padding: 40px 0; }
+.gl-loading span { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); animation: gl-breathe 1.2s var(--ov-ease-in-out, ease-in-out) infinite; }
+.gl-loading span:nth-child(2) { animation-delay: 0.2s; }
+.gl-loading span:nth-child(3) { animation-delay: 0.4s; }
+@media (prefers-reduced-motion: reduce) { .gl-wait-dot, .gl-loading span { animation: none; } .gl-slot, .gl-chip, .gl-title { transition: none; } }
+/* Video quality is a fold too: the same row as the others (its older rule had its own padding). */
+.gl-fold.bc-ws-quality > summary { padding: 8px 14px; min-height: 52px; font-size: 0.9rem; }
+.gl-fold.bc-ws-quality > summary > i:first-child { width: 18px; }
+.gl-fold .bc-sum-chev { flex: none; font-size: 0.75rem; }
+
+/* ✨ Suggest beside the title, and its three ideas under it: a tap puts one in the title. */
+.gl-suggest { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; padding: 0 12px; border-radius: 999px; cursor: pointer;
+ font-size: 0.82rem; font-weight: 700; color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
+ background: color-mix(in srgb, var(--accent) 8%, transparent); transition: background-color var(--ov-dur-2, .15s); }
+.gl-suggest:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
+.gl-suggest:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+@media (pointer: coarse) { .gl-suggest { min-height: 44px; } }
+.gl-ideas { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
+.gl-ideas[hidden] { display: none; }
+.gl-idea { all: unset; box-sizing: border-box; max-width: 100%; padding: 8px 12px; border-radius: 12px; cursor: pointer; font-size: 0.86rem; font-weight: 600; line-height: 1.3;
+ color: var(--text-primary); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); overflow-wrap: anywhere; }
+.gl-idea:hover { border-style: solid; }
+.gl-idea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.gl-ideas-note { margin: 0; font-size: 0.8rem; color: var(--text-muted); }
+/* A phone: the saved note drops under the title so the title keeps its width beside Suggest. */
+@media (max-width: 420px) {
+ .gl-title-row { flex-wrap: wrap; row-gap: 4px; }
+ .gl-title-row .gl-title { flex: 1 1 0; }
+ .gl-title-row .bc-ws-save-status { order: 3; flex-basis: 100%; text-align: left; }
+ .gl-title-row .bc-ws-save-status:empty { display: none; }
+}
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ -->
<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ -->
<div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live">
<div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live">
<h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</button></h2>
<h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</button></h2>
- <p class="bc-page-subtitle">Select a stream slot to configure your profile and go live.</p>
+ <p class="bc-page-subtitle">Check the card, press Go live. Everything else is in the rows under it.</p>
<!-- Guests: going live needs an account. broadcast.js shows this in place of the slot
<!-- Guests: going live needs an account. broadcast.js shows this in place of the slot
workspace and loads nothing else (_broadcastSignInGate). -->
workspace and loads nothing else (_broadcastSignInGate). -->
@@ -52,26 +52,17 @@
@@ -52,26 +52,17 @@
<div class="bc-top-tab-panel active" data-bctabpanel="mine">
<div class="bc-top-tab-panel active" data-bctabpanel="mine">
<div class="bc-workspace" id="bc-workspace">
<div class="bc-workspace" id="bc-workspace">
- <!-- Left sidebar: stream slot list -->
- <div class="bc-ws-sidebar">
- <div class="bc-ws-sidebar-hd">
- <span><i class="fa-solid fa-tower-broadcast"></i> My Streams</span>
- <button class="btn btn-small btn-ghost bc-ws-add-btn"
- onclick="showCreateManagedStreamModal()" title="Add stream slot">
- <i class="fa-solid fa-plus"></i>
- </button>
- </div>
- <div class="bc-ws-list" id="bc-ws-list">
- <p class="muted" style="padding:12px"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</p>
- </div>
- </div>
+ <!-- Your slots: a row of pills (method icon, name, live), then New slot (broadcast-workspace.js) -->
+ <nav class="bc-ws-sidebar gl-slots" aria-label="Your stream slots">
+ <div class="bc-ws-list" id="bc-ws-list"></div>
+ </nav>
<!-- Right main panel -->
<!-- Right main panel -->
<div class="bc-ws-main">
<div class="bc-ws-main">
<!-- Empty state: no stream selected -->
<!-- Empty state: no stream selected -->
<div class="bc-ws-empty" id="bc-ws-empty">
<div class="bc-ws-empty" id="bc-ws-empty">
<i class="fa-solid fa-tower-broadcast" style="font-size:2.5rem;opacity:0.2;margin-bottom:16px"></i>
<i class="fa-solid fa-tower-broadcast" style="font-size:2.5rem;opacity:0.2;margin-bottom:16px"></i>
- <p class="muted" style="margin:0 0 16px">Select a stream slot from the sidebar to configure and go live</p>
+ <p class="muted" style="margin:0 0 16px">A slot is one stream of yours: its title, its link, how it goes out and where else it is sent. Make one to go live.</p>
<button class="btn btn-outline" onclick="showCreateManagedStreamModal()">
<button class="btn btn-outline" onclick="showCreateManagedStreamModal()">
<i class="fa-solid fa-plus"></i> Create stream slot
<i class="fa-solid fa-plus"></i> Create stream slot
</button>
</button>
@@ -79,32 +79,22 @@ async function _wsLoadManagedStreams() {
@@ -79,32 +79,22 @@ async function _wsLoadManagedStreams() {
function _wsRenderSidebar() {
function _wsRenderSidebar() {
const list = document.getElementById('bc-ws-list');
const list = document.getElementById('bc-ws-list');
if (!list) return;
if (!list) return;
-
+ const add = '<button type="button" class="gl-slot gl-slot--add" onclick="showCreateManagedStreamModal()"><i class="fa-solid fa-plus" aria-hidden="true"></i><span>New slot</span></button>';
if (_wsState.managedStreams.length === 0) {
if (_wsState.managedStreams.length === 0) {
- list.innerHTML = '<p class="bc-ws-empty-hint muted">No stream slots yet.<br>Click <strong>+</strong> to create your first one.</p>';
+ list.innerHTML = add;
return;
return;
}
}
-
list.innerHTML = _wsState.managedStreams.map(ms => {
list.innerHTML = _wsState.managedStreams.map(ms => {
const isSelected = ms.id === _wsState.selectedId;
const isSelected = ms.id === _wsState.selectedId;
const isLive = _wsIsManagedStreamLive(ms.id);
const isLive = _wsIsManagedStreamLive(ms.id);
- const proto = _wsMethodLabel(ms.streaming_method || ms.protocol);
- return `
- <div class="bc-ws-item${isSelected ? ' selected' : ''}" onclick="wsSelectStream(${ms.id})">
- <div class="bc-ws-item-status${isLive ? ' live' : ''}"></div>
- <div class="bc-ws-item-body">
- <div class="bc-ws-item-name">${esc(ms.title || 'Untitled')}</div>
- <div class="bc-ws-item-meta">
- <span class="bc-ws-item-proto">${esc(proto)}</span>
- ${ms.slug ? `<span class="bc-ws-item-slug">/${esc(ms.slug)}</span>` : `<span class="bc-ws-item-slug">#${ms.id}</span>`}
- ${isLive ? '<span class="bc-ws-live-dot"><i class="fa-solid fa-circle"></i> LIVE</span>' : ''}
- </div>
- </div>
- <button class="bc-ws-item-del" onclick="event.stopPropagation();_wsConfirmDelete(${ms.id})" title="Delete slot">
- <i class="fa-solid fa-xmark"></i>
- </button>
- </div>`;
- }).join('');
+ const method = ms.streaming_method || ms.protocol;
+ return `<button type="button" class="gl-slot${isSelected ? ' is-on' : ''}${isLive ? ' is-live' : ''}" aria-pressed="${isSelected}" onclick="wsSelectStream(${ms.id})"
+ title="${esc(_wsMethodLabel(method))}${ms.slug ? ' · /' + esc(ms.slug) : ''}">
+ <i class="fa-solid fa-${_glMethodIcon(method, ms.browser_mode)}" aria-hidden="true"></i>
+ <span class="gl-slot-name">${esc(ms.title || 'Untitled')}</span>
+ ${isLive ? '<span class="gl-slot-live">Live</span>' : ''}
+ </button>`;
+ }).join('') + add;
}
}
/** Human-readable method label for sidebar/panel */
/** Human-readable method label for sidebar/panel */
@@ -132,12 +122,7 @@ function _wsIsManagedStreamLive(managedStreamId) {
@@ -132,12 +122,7 @@ function _wsIsManagedStreamLive(managedStreamId) {
// Public – called from sidebar onclick
// Public – called from sidebar onclick
async function wsSelectStream(managedStreamId) {
async function wsSelectStream(managedStreamId) {
if (_wsState.selectedId === managedStreamId) return;
if (_wsState.selectedId === managedStreamId) return;
- if (_wsState.dirty) {
- const action = await _wsConfirmUnsaved('You have unsaved changes. What would you like to do?');
- if (action === 'save') await _wsSaveAll();
- if (action === 'cancel') return;
- // 'discard' falls through
- }
+ await _wsFlushSave();
await _wsSelectStream(managedStreamId);
await _wsSelectStream(managedStreamId);
}
}
@@ -154,7 +139,7 @@ async function _wsSelectStream(managedStreamId) {
@@ -154,7 +139,7 @@ async function _wsSelectStream(managedStreamId) {
if (empty) empty.style.display = 'none';
if (empty) empty.style.display = 'none';
if (panel) {
if (panel) {
panel.style.display = '';
panel.style.display = '';
- panel.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-muted)"><i class="fa-solid fa-spinner fa-spin"></i> Loading stream profile\u2026</div>';
+ panel.innerHTML = '<div class="gl-loading" aria-busy="true"><span></span><span></span><span></span></div>';
}
}
await _wsLoadProfile(managedStreamId);
await _wsLoadProfile(managedStreamId);
@@ -231,27 +216,41 @@ function _wsRenderPanel() {
@@ -231,27 +216,41 @@ function _wsRenderPanel() {
// Determine browser submode from managed stream or profile
// Determine browser submode from managed stream or profile
const browserMode = ms.browser_mode || p.browserMode || 'camera';
const browserMode = ms.browser_mode || p.browserMode || 'camera';
- panel.innerHTML = `
- <!-- ── Panel header ── -->
- <div class="bc-ws-panel-hd">
- <div class="bc-ws-panel-title">
- <h2>${esc(ms.title || 'Untitled Stream')}</h2>
- <span class="bc-ws-slug muted">
- ${currentUser?.username
- ? `<a href="${channelPath(currentUser.username, ms.slug || ms.id)}" target="_blank" style="color:var(--text-muted);text-decoration:none"><i class="fa-solid fa-link" style="font-size:0.7rem"></i> openvibe.live${channelPath(currentUser.username, ms.slug || ms.id)}</a>`
- : ms.slug
- ? `<i class="fa-solid fa-link" style="font-size:0.7rem"></i> openvibe.live/${esc(ms.slug)}`
- : `<i class="fa-solid fa-hashtag" style="font-size:0.7rem"></i> Stream #${ms.id}`}
- </span>
- </div>
- <div style="display:flex;align-items:center;gap:8px">
- ${isLive ? '<span class="bc-live-badge bc-ws-live-badge-hd"><i class="fa-solid fa-circle"></i> LIVE</span>' : ''}
- <button class="btn btn-small btn-ghost bc-ws-popout-chat-btn" onclick="_wsOpenPopoutChat()" title="Popout Chat">
- <i class="fa-solid fa-comment-dots"></i> Chat
- </button>
- </div>
- </div>
+ const link = currentUser?.username
+ ? `openvibe.live${channelPath(currentUser.username, ms.slug || ms.id)}`
+ : (ms.slug ? `openvibe.live/${ms.slug}` : `Stream #${ms.id}`);
+ const isBrowser = method === 'browser';
+ const endpoint = ` <div class="bc-ws-endpoint-section">
+ <div class="bc-ws-endpoint-hd">
+ <h3><i class="fa-solid fa-plug"></i> Your server and key</h3>
+ </div>
+ <p class="bc-ws-endpoint-note">
+ This key belongs to <strong>${esc(ms.title || 'this stream slot')}</strong>.
+ Each slot has its own stable key. Regenerating invalidates the current key immediately.
+ </p>
+ <div class="bc-ws-key-row">
+ <div class="bc-ws-key-display">
+ <input type="password" id="bc-ws-stream-key" class="form-input form-input-sm bc-ws-key-input"
+ value="${esc(streamKey)}" readonly autocomplete="off" spellcheck="false"
+ aria-label="Stream key">
+ <button class="btn btn-small btn-ghost bc-ws-key-toggle"
+ onclick="_wsToggleKeyVisibility()" title="Show / hide key">
+ <i class="fa-solid fa-eye" id="bc-ws-key-eye"></i>
+ </button>
+ <button class="btn btn-small btn-ghost"
+ onclick="_wsCopyStreamKey()" title="Copy stream key">
+ <i class="fa-solid fa-copy"></i>
+ </button>
+ </div>
+ <button class="btn btn-small btn-outline bc-ws-regen-btn"
+ onclick="_wsRegenerateKey(${ms.id})">
+ <i class="fa-solid fa-arrows-rotate"></i> Regen Key
+ </button>
+ </div>
+ <div id="bc-ws-method-endpoint">${_wsRenderMethodEndpoint(method, streamKey, ms.id)}</div>
+ </div>`;
+ panel.innerHTML = `
<!-- ── Live session banner (expanded with stats) ── -->
<!-- ── Live session banner (expanded with stats) ── -->
<div id="bc-ws-live-banner" style="${liveSessionId ? '' : 'display:none'}">
<div id="bc-ws-live-banner" style="${liveSessionId ? '' : 'display:none'}">
<div class="bc-ws-live-banner">
<div class="bc-ws-live-banner">
@@ -293,35 +292,128 @@ function _wsRenderPanel() {
@@ -293,35 +292,128 @@ function _wsRenderPanel() {
</div>
</div>
</div>
</div>
- <!-- ── Tabs ── -->
- <div class="bc-ws-tabs" id="bc-ws-tabs">
- <button class="bc-ws-tab active" data-wstab="profile" onclick="_wsTabSwitch('profile')">
- <i class="fa-solid fa-sliders"></i> Stream
- </button>
- <button class="bc-ws-tab" data-wstab="settings" onclick="_wsTabSwitch('settings')">
- <i class="fa-solid fa-gear"></i> Settings
- </button>
- <button class="bc-ws-tab" data-wstab="endpoint" onclick="_wsTabSwitch('endpoint')">
- <i class="fa-solid fa-plug"></i> Endpoint
- </button>
- <button class="bc-ws-tab" data-wstab="history" onclick="_wsTabSwitch('history')">
- <i class="fa-solid fa-clock-rotate-left"></i> History
- </button>
- </div>
-
- <!-- ═══ TAB: Stream Profile ═══ -->
- <div class="bc-ws-tab-panel active" data-wstabpanel="profile">
- <div class="bc-ws-profile-section bc-ws-profile-split">
- <div class="bc-ws-col bc-ws-col--details">
+ <div class="gl">
+ <section class="gl-card${isBrowser ? ' is-browser' : ''}" aria-label="Your stream, ready to go live">
+ <div class="gl-title-row">
+ <input type="text" id="bc-title" class="gl-title" aria-label="Title"
+ value="${esc(ms.title || '')}"
+ placeholder="What are you streaming?" maxlength="200"
+ oninput="_wsMarkDirty()">
+ <span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
+ <button type="button" class="gl-suggest" id="gl-suggest" onclick="_glSuggestTitles(this)" title="Three title ideas from your last streams on this slot">
+ <span aria-hidden="true">\u2728</span> Suggest
+ </button>
+ </div>
+ <div class="gl-ideas" id="gl-ideas" role="group" aria-label="Title ideas" hidden></div>
+ <div class="gl-chips">
+ <label class="gl-chip gl-chip--mature" title="Mature (18+): viewers confirm their age before the stream plays. The category is set automatically from what is on stream.">
+ <input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()">
+ <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i><span>Mature</span><b id="gl-mature-v">${ms.is_nsfw ? 'on' : 'off'}</b>
+ </label>
+ <button type="button" class="gl-chip gl-chip--link" onclick="_glCopyLink()" title="Copy your stream's address">
+ <i class="fa-solid fa-link" aria-hidden="true"></i><span id="gl-link">${esc(link)}</span>
+ </button>
+ <button type="button" class="gl-chip" onclick="_glOpenFold('bc-ws-restream-details')" title="Where this stream is sent too">
+ <i class="fa-solid fa-share-nodes" aria-hidden="true"></i><span>Restreams</span><b id="gl-rs-v">…</b>
+ </button>
+ <button type="button" class="gl-chip" onclick="_wsOpenPopoutChat()" title="Open your chat in its own window">
+ <i class="fa-solid fa-comment-dots" aria-hidden="true"></i><span>Chat</span>
+ </button>
+ </div>
- <div class="form-group">
- <label>Title</label>
- <input type="text" id="bc-title" class="form-input"
- value="${esc(ms.title || '')}"
- placeholder="What are you streaming?" maxlength="200"
- oninput="_wsMarkDirty()">
+ ${isBrowser ? `
+ <div class="bc-ws-golive-section is-browser" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>
+ <!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens and
+ the microphone; remembered on this device, never saved with the slot. -->
+ <div id="bc-ws-device-wrap">
+ <div id="bc-perm-request" class="bc-perm-request">
+ <p class="bc-perm-hint"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
+ <span id="bc-perm-hint-text">${_wsPermHint(browserMode)}</span></p>
+ <button id="bc-perm-btn" class="btn btn-outline" type="button"
+ onclick="requestMediaPermissions()">
+ <i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'
+ : browserMode === 'screen' ? 'fa-display'
+ : 'fa-video'}"></i>
+ <span id="bc-perm-btn-text">${browserMode === 'mic_only' ? 'Allow Microphone'
+ : browserMode === 'camera_only' ? 'Allow Camera'
+ : browserMode === 'screen' ? 'Allow Screen Share'
+ : 'Allow Camera & Mic'}</span>
+ </button>
+ <p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>
+ </div>
+ <div id="bc-device-selects" style="display:none">
+ <div class="bc-setup-card">
+ <div class="bc-setup-item" id="bc-ws-camera-group">
+ <div class="bc-setup-row">
+ <span class="bc-setup-label"><i class="fa-solid fa-camera" aria-hidden="true"></i>Camera</span>
+ <div class="bc-cam-select-row bc-setup-pick">
+ <span class="bc-setup-val" data-pick-for="bc-create-camera">Default</span>
+ <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>
+ <select id="bc-create-camera" class="bc-setup-native" aria-label="Camera">
+ <option value="default">Default</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ <div class="bc-setup-item" id="bc-ws-audio-group">
+ <div class="bc-setup-row">
+ <span class="bc-setup-label"><i class="fa-solid fa-microphone" aria-hidden="true"></i>Microphone</span>
+ <div class="bc-setup-pick">
+ <span class="bc-setup-val" data-pick-for="bc-create-audio">Default</span>
+ <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>
+ <select id="bc-create-audio" class="bc-setup-native" aria-label="Microphone">
+ <option value="default">Default</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+ <p class="bc-setup-note">
+ <span>Remembered on this device</span>
+ <button type="button" class="bc-setup-link" onclick="refreshDeviceLists()"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Refresh devices</button>
+ </p>
+ </div>
</div>
</div>
+ <!-- Screen-share hidden defaults -->
+ <input type="checkbox" id="bc-screen-mic-enabled" checked style="display:none">
+ <input type="checkbox" id="bc-screen-cam-enabled" style="display:none">
+ <input type="checkbox" id="bc-screenPreferCurrentTab-create" style="display:none">
+ <select id="bc-screenSystemAudio-create" style="display:none"><option value="include" selected>Include</option><option value="auto">Auto</option><option value="exclude">Exclude</option></select>
+ <select id="bc-screenSelfBrowser-create" style="display:none"><option value="exclude" selected>Exclude</option><option value="include">Include</option></select>
+ <select id="bc-screenSurfaceSwitching-create" style="display:none"><option value="include" selected>Allow</option><option value="exclude">Disallow</option></select>
+ <select id="bc-screen-audio" style="display:none"><option value="default">Default</option></select>
+ <select id="bc-screen-camera" style="display:none"><option value="default">Default</option></select>
+
+ ${_wsPreflight(browserMode)}
+
+ <button class="btn btn-primary btn-lg bc-ws-golive-btn"
+ onclick="goLiveFromWorkspace()" id="bc-ws-golive-btn">
+ <i class="fa-solid fa-tower-broadcast"></i> Go Live
+ </button>
+ <p class="bc-create-reassurance" style="margin-top:6px;font-size:0.82rem">
+ <i class="fa-solid fa-circle-info"></i>
+ Live at <strong>openvibe.live${channelPath(currentUser?.username || 'your-channel', ms.slug || ms.id)}</strong>
+ </p>
+ </div>
+ ` : `
+ <div class="gl-wait" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>
+ <span class="gl-wait-dot" aria-hidden="true"></span>
+ <div>
+ <b>Waiting for your ${method === 'rtmp' ? 'streaming app' : method === 'whip' ? 'WHIP encoder' : 'encoder'}…</b>
+ <small>This stream goes live by itself when ${method === 'rtmp' ? 'your app connects over RTMP'
+ : method === 'whip' ? 'your encoder connects over WHIP'
+ : 'FFmpeg or your tool sends to the address below'}. Nothing to press here.</small>
+ </div>
+ </div>
+ ${endpoint}
+ `}
+ </section>
+
+ <div class="gl-folds">
+ <details class="gl-fold" id="gl-details">
+ <summary><i class="fa-solid fa-pen" aria-hidden="true"></i><span class="bc-sum-title">Details</span><small class="bc-sum-val" id="gl-details-sum">${_glDetailsSummary(ms)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
+ <div class="gl-fold-body">
<div class="form-group">
<div class="form-group">
<label>Description</label>
<label>Description</label>
<textarea id="bc-description" class="form-input" rows="2"
<textarea id="bc-description" class="form-input" rows="2"
@@ -329,13 +421,6 @@ function _wsRenderPanel() {
@@ -329,13 +421,6 @@ function _wsRenderPanel() {
oninput="_wsMarkDirty()">${esc(ms.description || '')}</textarea>
oninput="_wsMarkDirty()">${esc(ms.description || '')}</textarea>
</div>
</div>
- <!-- No category picker: the AI decides it from the stream itself (streams.ai_category wins everywhere). -->
- <label class="bc-switch">
- <input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()">
- <span class="bc-switch-track" aria-hidden="true"><span class="bc-switch-thumb"></span></span>
- <span class="bc-switch-text"><b><i class="fa-solid fa-triangle-exclamation"></i> Mature content (NSFW)</b><small>Viewers confirm their age before the stream plays. The category is set automatically from what is on stream.</small></span>
- </label>
-
<!-- URL Slug -->
<!-- URL Slug -->
<div class="form-group">
<div class="form-group">
<label>URL Slug <span class="muted">(optional)</span></label>
<label>URL Slug <span class="muted">(optional)</span></label>
@@ -346,12 +431,16 @@ function _wsRenderPanel() {
@@ -346,12 +431,16 @@ function _wsRenderPanel() {
oninput="_wsMarkDirty()">
oninput="_wsMarkDirty()">
</div>
</div>
</div>
</div>
+ <button type="button" class="gl-danger-link" onclick="_wsConfirmDelete(${ms.id})"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete this slot</button>
+ </div>
+ </details>
- </div>
- <div class="bc-ws-col bc-ws-col--method">
+ <details class="gl-fold" id="gl-how">
+ <summary><i class="fa-solid fa-${_glMethodIcon(method, browserMode)}" aria-hidden="true"></i><span class="bc-sum-title">How you stream</span><small class="bc-sum-val" id="gl-how-sum">${esc(_glHowSummary(method, browserMode))}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
+ <div class="gl-fold-body bc-ws-col--method">
<!-- ═══ Streaming Method ═══ -->
<!-- ═══ Streaming Method ═══ -->
<div class="form-group">
<div class="form-group">
- <label>Streaming Method</label>
+ <label>Where the picture comes from</label>
<div class="bc-method-picker bc-method-picker-sm" id="bc-ws-method-picker" role="radiogroup" aria-label="Streaming method">
<div class="bc-method-picker bc-method-picker-sm" id="bc-ws-method-picker" role="radiogroup" aria-label="Streaming method">
${_wsRenderMethodCards(method)}
${_wsRenderMethodCards(method)}
</div>
</div>
@@ -361,7 +450,7 @@ function _wsRenderPanel() {
@@ -361,7 +450,7 @@ function _wsRenderPanel() {
<!-- ═══ Browser Sub-mode (only when method is browser) ═══ -->
<!-- ═══ Browser Sub-mode (only when method is browser) ═══ -->
<div id="bc-ws-browser-submodes" style="${method === 'browser' ? '' : 'display:none'}">
<div id="bc-ws-browser-submodes" style="${method === 'browser' ? '' : 'display:none'}">
<div class="form-group">
<div class="form-group">
- <label>Browser Capture Mode</label>
+ <label>What your browser sends</label>
<div class="bc-method-picker bc-method-picker-sm" id="bc-ws-browser-mode-picker" role="radiogroup" aria-label="Browser capture mode">
<div class="bc-method-picker bc-method-picker-sm" id="bc-ws-browser-mode-picker" role="radiogroup" aria-label="Browser capture mode">
${_wsRenderBrowserModeCards(browserMode)}
${_wsRenderBrowserModeCards(browserMode)}
</div>
</div>
@@ -512,9 +601,12 @@ function _wsRenderPanel() {
@@ -512,9 +601,12 @@ function _wsRenderPanel() {
</div>
</div>
</div>
</div>
+ </div>
+ </details>
+
<!-- Video quality defaults (Class A blob, browser method only) -->
<!-- Video quality defaults (Class A blob, browser method only) -->
<div id="bc-ws-quality-wrap" style="${method === 'browser' ? '' : 'display:none'}">
<div id="bc-ws-quality-wrap" style="${method === 'browser' ? '' : 'display:none'}">
- <details class="bc-ws-quality">
+ <details class="gl-fold bc-ws-quality" id="gl-quality">
<summary><i class="fa-solid fa-film"></i><span class="bc-sum-title">Video quality</span><small class="bc-sum-val">${_wsQualitySummaryHTML(p.resolution || '720', p.fps || '30', p.bitrate || 2500)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<summary><i class="fa-solid fa-film"></i><span class="bc-sum-title">Video quality</span><small class="bc-sum-val">${_wsQualitySummaryHTML(p.resolution || '720', p.fps || '30', p.bitrate || 2500)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-quality-inner bc-ws-row">
<div class="bc-ws-quality-inner bc-ws-row">
<div class="form-group" style="margin:0;flex:1">
<div class="form-group" style="margin:0;flex:1">
@@ -546,116 +638,9 @@ function _wsRenderPanel() {
@@ -546,116 +638,9 @@ function _wsRenderPanel() {
</details>
</details>
</div>
</div>
- </div>
-
- <!-- Hidden element required by createNewStream() -->
- <select id="bc-managed-stream" style="display:none">
- <option value="${ms.id}" selected>${esc(ms.title || '')}</option>
- </select>
- </div>
-
- <!-- ── Go Live / Auto-detect section ── -->
- <div class="bc-ws-golive-section${method === 'browser' ? ' is-browser' : ''}" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>
-
- ${method === 'browser' ? `
- <!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens and
- the microphone; remembered on this device, never saved with the slot. -->
- <div id="bc-ws-device-wrap">
- <div id="bc-perm-request" class="bc-perm-request">
- <p class="bc-perm-hint"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
- <span id="bc-perm-hint-text">${_wsPermHint(browserMode)}</span></p>
- <button id="bc-perm-btn" class="btn btn-outline" type="button"
- onclick="requestMediaPermissions()">
- <i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'
- : browserMode === 'screen' ? 'fa-display'
- : 'fa-video'}"></i>
- <span id="bc-perm-btn-text">${browserMode === 'mic_only' ? 'Allow Microphone'
- : browserMode === 'camera_only' ? 'Allow Camera'
- : browserMode === 'screen' ? 'Allow Screen Share'
- : 'Allow Camera & Mic'}</span>
- </button>
- <p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>
- </div>
- <div id="bc-device-selects" style="display:none">
- <div class="bc-setup-card">
- <div class="bc-setup-item" id="bc-ws-camera-group">
- <div class="bc-setup-row">
- <span class="bc-setup-label"><i class="fa-solid fa-camera" aria-hidden="true"></i>Camera</span>
- <div class="bc-cam-select-row bc-setup-pick">
- <span class="bc-setup-val" data-pick-for="bc-create-camera">Default</span>
- <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>
- <select id="bc-create-camera" class="bc-setup-native" aria-label="Camera">
- <option value="default">Default</option>
- </select>
- </div>
- </div>
- </div>
- <div class="bc-setup-item" id="bc-ws-audio-group">
- <div class="bc-setup-row">
- <span class="bc-setup-label"><i class="fa-solid fa-microphone" aria-hidden="true"></i>Microphone</span>
- <div class="bc-setup-pick">
- <span class="bc-setup-val" data-pick-for="bc-create-audio">Default</span>
- <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>
- <select id="bc-create-audio" class="bc-setup-native" aria-label="Microphone">
- <option value="default">Default</option>
- </select>
- </div>
- </div>
- </div>
- </div>
- <p class="bc-setup-note">
- <span>Remembered on this device</span>
- <button type="button" class="bc-setup-link" onclick="refreshDeviceLists()"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Refresh devices</button>
- </p>
- </div>
- </div>
-
- <!-- Screen-share hidden defaults -->
- <input type="checkbox" id="bc-screen-mic-enabled" checked style="display:none">
- <input type="checkbox" id="bc-screen-cam-enabled" style="display:none">
- <input type="checkbox" id="bc-screenPreferCurrentTab-create" style="display:none">
- <select id="bc-screenSystemAudio-create" style="display:none"><option value="include" selected>Include</option><option value="auto">Auto</option><option value="exclude">Exclude</option></select>
- <select id="bc-screenSelfBrowser-create" style="display:none"><option value="exclude" selected>Exclude</option><option value="include">Include</option></select>
- <select id="bc-screenSurfaceSwitching-create" style="display:none"><option value="include" selected>Allow</option><option value="exclude">Disallow</option></select>
- <select id="bc-screen-audio" style="display:none"><option value="default">Default</option></select>
- <select id="bc-screen-camera" style="display:none"><option value="default">Default</option></select>
-
- ${_wsPreflight(browserMode)}
-
- <button class="btn btn-primary btn-lg bc-ws-golive-btn"
- onclick="goLiveFromWorkspace()" id="bc-ws-golive-btn">
- <i class="fa-solid fa-tower-broadcast"></i> Go Live
- </button>
- <p class="bc-create-reassurance" style="margin-top:6px;font-size:0.82rem">
- <i class="fa-solid fa-circle-info"></i>
- Live at <strong>openvibe.live${channelPath(currentUser?.username || 'your-channel', ms.slug || ms.id)}</strong>
- </p>
- ` : `
- <!-- Auto-detect notice for non-browser methods -->
- <div class="bc-ws-autodetect">
- <div class="bc-ws-autodetect-pulse"></div>
- <div>
- <strong>Auto-detect enabled</strong><br>
- Your stream will go live automatically when ${method === 'rtmp' ? 'your streaming software connects via RTMP'
- : method === 'whip' ? 'your encoder connects via WHIP'
- : 'FFmpeg or your streaming tool sends data to the endpoint'}.
- Configure your software using the <strong>Endpoint</strong> tab, then just start streaming.
- </div>
- </div>
- <button class="btn btn-small btn-outline" onclick="_wsTabSwitch('endpoint')" style="width:100%">
- <i class="fa-solid fa-plug"></i> View Endpoint & Setup Instructions
- </button>
- `}
- </div>
- </div>
-
- <!-- ═══ TAB: Settings ═══ -->
- <div class="bc-ws-tab-panel" data-wstabpanel="settings">
- <div class="bc-ws-profile-section">
-
<!-- Picture-in-picture camera -->
<!-- Picture-in-picture camera -->
- <details class="bc-ws-slot-settings" id="bc-ws-pip-details">
- <summary><i class="fa-solid fa-clone"></i> Picture-in-picture</summary>
+ <details class="gl-fold" id="bc-ws-pip-details">
+ <summary><i class="fa-solid fa-clone" aria-hidden="true"></i><span class="bc-sum-title">Picture-in-picture</span><small class="bc-sum-val" id="gl-pip-sum">None</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
Show up to three other streams over this one: your webcam on a slot of its own, a second
Show up to three other streams over this one: your webcam on a slot of its own, a second
@@ -669,14 +654,14 @@ function _wsRenderPanel() {
@@ -669,14 +654,14 @@ function _wsRenderPanel() {
</details>
</details>
<!-- Restream Destinations -->
<!-- Restream Destinations -->
- <details class="bc-ws-slot-settings" id="bc-ws-restream-details" open>
- <summary><i class="fa-solid fa-tower-broadcast"></i> Restream Destinations</summary>
+ <details class="gl-fold" id="bc-ws-restream-details">
+ <summary><i class="fa-solid fa-share-nodes" aria-hidden="true"></i><span class="bc-sum-title">Restreams</span><small class="bc-sum-val" id="gl-rs-sum">…</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
Stream this slot to external platforms (Twitch, YouTube, Kick, etc.). Each slot can have its own destinations.
Stream this slot to external platforms (Twitch, YouTube, Kick, etc.). Each slot can have its own destinations.
</p>
</p>
<div id="bc-ws-restream-list" class="bc-ws-restream-list">
<div id="bc-ws-restream-list" class="bc-ws-restream-list">
- <div class="bc-ws-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</div>
+ <div class="bc-ws-loading">Loading your destinations…</div>
</div>
</div>
<button class="btn btn-small btn-outline" style="margin-top:8px" onclick="_wsAddRestreamDest()">
<button class="btn btn-small btn-outline" style="margin-top:8px" onclick="_wsAddRestreamDest()">
<i class="fa-solid fa-plus"></i> Add Destination
<i class="fa-solid fa-plus"></i> Add Destination
@@ -685,8 +670,8 @@ function _wsRenderPanel() {
@@ -685,8 +670,8 @@ function _wsRenderPanel() {
</details>
</details>
<!-- Chat Overlay -->
<!-- Chat Overlay -->
- <details class="bc-ws-slot-settings">
- <summary><i class="fa-solid fa-closed-captioning"></i> Chat Overlay</summary>
+ <details class="gl-fold">
+ <summary><i class="fa-solid fa-closed-captioning" aria-hidden="true"></i><span class="bc-sum-title">Chat on stream</span><small class="bc-sum-val">An OBS browser source</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<p class="muted" style="font-size:0.82rem;margin-bottom:8px">
<p class="muted" style="font-size:0.82rem;margin-bottom:8px">
Add this URL as a Browser Source in OBS to display chat on stream.
Add this URL as a Browser Source in OBS to display chat on stream.
@@ -716,8 +701,8 @@ function _wsRenderPanel() {
@@ -716,8 +701,8 @@ function _wsRenderPanel() {
</div>
</div>
</details>
</details>
- <details class="bc-ws-slot-settings">
- <summary><i class="fa-solid fa-code"></i> Vibe Coding Feed</summary>
+ <details class="gl-fold">
+ <summary><i class="fa-solid fa-code" aria-hidden="true"></i><span class="bc-sum-title">Vibe coding feed</span><small class="bc-sum-val">Set up on openvibe.chat</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
Show what you are building while you stream: Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and viewers follow along in a card above this slot's chat. Feeds live on OpenVibe Chat: make one there, pick this slot, and paste its Claude Code hooks. Secrets are wiped before anyone sees a step.
Show what you are building while you stream: Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and viewers follow along in a card above this slot's chat. Feeds live on OpenVibe Chat: make one there, pick this slot, and paste its Claude Code hooks. Secrets are wiped before anyone sees a step.
@@ -727,8 +712,8 @@ function _wsRenderPanel() {
@@ -727,8 +712,8 @@ function _wsRenderPanel() {
</details>
</details>
<!-- PowerChat overlay relay (shown only when PowerChat is connected) -->
<!-- PowerChat overlay relay (shown only when PowerChat is connected) -->
- <details class="bc-ws-slot-settings bc-ws-pc-only">
- <summary><i class="fa-solid fa-bolt"></i> PowerChat Overlay</summary>
+ <details class="gl-fold bc-ws-pc-only">
+ <summary><i class="fa-solid fa-bolt" aria-hidden="true"></i><span class="bc-sum-title">PowerChat overlay</span><small class="bc-sum-val" id="gl-pc-sum">${ms.slot_powerchat_relay !== 0 ? 'Relaying chat' : 'Not relaying'}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-row">
<div class="bc-ws-row">
<div class="form-group" style="flex:1">
<div class="form-group" style="flex:1">
@@ -759,14 +744,14 @@ function _wsRenderPanel() {
@@ -759,14 +744,14 @@ function _wsRenderPanel() {
</details>
</details>
<!-- AI Chat Viewers (v3) — compact per-slot controls; full panel lives on the dashboard -->
<!-- AI Chat Viewers (v3) — compact per-slot controls; full panel lives on the dashboard -->
- <details class="bc-ws-slot-settings" id="bc-ws-aiv-details">
- <summary><i class="fa-solid fa-robot"></i> AI Chat Viewers</summary>
+ <details class="gl-fold" id="bc-ws-aiv-details">
+ <summary><i class="fa-solid fa-robot" aria-hidden="true"></i><span class="bc-sum-title">AI chat viewers</span><small class="bc-sum-val">For this slot</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner"><div id="bc-ws-aiv" data-ms="${ms.id}"><span class="muted">Loading…</span></div></div>
<div class="bc-ws-slot-settings-inner"><div id="bc-ws-aiv" data-ms="${ms.id}"><span class="muted">Loading…</span></div></div>
</details>
</details>
<!-- VOD / Clips Settings -->
<!-- VOD / Clips Settings -->
- <details class="bc-ws-slot-settings">
- <summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>
+ <details class="gl-fold">
+ <summary><i class="fa-solid fa-film" aria-hidden="true"></i><span class="bc-sum-title">Recording</span><small class="bc-sum-val" id="gl-rec-sum">${_glRecSummary(ms)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-row">
<div class="bc-ws-row">
<div class="form-group" style="flex:1">
<div class="form-group" style="flex:1">
@@ -815,8 +800,8 @@ function _wsRenderPanel() {
@@ -815,8 +800,8 @@ function _wsRenderPanel() {
</details>
</details>
<!-- Weather Widget Settings -->
<!-- Weather Widget Settings -->
- <details class="bc-ws-slot-settings">
- <summary><i class="fa-solid fa-cloud-sun"></i> Weather Widget Settings</summary>
+ <details class="gl-fold">
+ <summary><i class="fa-solid fa-cloud-sun" aria-hidden="true"></i><span class="bc-sum-title">Weather on stream</span><small class="bc-sum-val" id="gl-weather-sum">${_glWeatherSummary(ms)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-row">
<div class="bc-ws-row">
<div class="form-group" style="flex:2">
<div class="form-group" style="flex:2">
@@ -845,55 +830,31 @@ function _wsRenderPanel() {
@@ -845,55 +830,31 @@ function _wsRenderPanel() {
</div>
</div>
</details>
</details>
- </div>
- </div>
- <!-- ═══ TAB: Endpoint & Key ═══ -->
- <div class="bc-ws-tab-panel" data-wstabpanel="endpoint">
- <div class="bc-ws-endpoint-section">
- <div class="bc-ws-endpoint-hd">
- <h3><i class="fa-solid fa-plug"></i> Stream Endpoint & Key</h3>
- </div>
- <p class="bc-ws-endpoint-note">
- This key belongs to <strong>${esc(ms.title || 'this stream slot')}</strong>.
- Each slot has its own stable key. Regenerating invalidates the current key immediately.
- </p>
- <div class="bc-ws-key-row">
- <div class="bc-ws-key-display">
- <input type="password" id="bc-ws-stream-key" class="form-input form-input-sm bc-ws-key-input"
- value="${esc(streamKey)}" readonly autocomplete="off" spellcheck="false"
- aria-label="Stream key">
- <button class="btn btn-small btn-ghost bc-ws-key-toggle"
- onclick="_wsToggleKeyVisibility()" title="Show / hide key">
- <i class="fa-solid fa-eye" id="bc-ws-key-eye"></i>
- </button>
- <button class="btn btn-small btn-ghost"
- onclick="_wsCopyStreamKey()" title="Copy stream key">
- <i class="fa-solid fa-copy"></i>
- </button>
+ ${isBrowser ? `
+ <details class="gl-fold" id="gl-endpoint">
+ <summary><i class="fa-solid fa-plug" aria-hidden="true"></i><span class="bc-sum-title">Stream from an app instead</span><small class="bc-sum-val">OBS, WHIP or FFmpeg, with your key</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
+ <div class="gl-fold-body">${endpoint}</div>
+ </details>` : ''}
+
+ <details class="gl-fold" id="gl-history">
+ <summary><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i><span class="bc-sum-title">Past streams</span><small class="bc-sum-val" id="gl-hist-sum">…</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
+ <div class="gl-fold-body">
+ <div id="bc-ws-history-list" class="bc-ws-history-list">
+ <p class="muted" style="padding:4px 0">Loading your past streams…</p>
</div>
</div>
- <button class="btn btn-small btn-outline bc-ws-regen-btn"
- onclick="_wsRegenerateKey(${ms.id})">
- <i class="fa-solid fa-arrows-rotate"></i> Regen Key
- </button>
</div>
</div>
- <div id="bc-ws-method-endpoint">${_wsRenderMethodEndpoint(method, streamKey, ms.id)}</div>
- </div>
+ </details>
</div>
</div>
-
- <!-- ═══ TAB: History ═══ -->
- <div class="bc-ws-tab-panel" data-wstabpanel="history">
- <div class="bc-ws-profile-section">
- <h3><i class="fa-solid fa-clock-rotate-left"></i> Recent Sessions</h3>
- <div id="bc-ws-history-list" class="bc-ws-history-list">
- <p class="muted" style="padding:12px 0"><i class="fa-solid fa-spinner fa-spin"></i> Loading\u2026</p>
- </div>
- </div>
</div>
</div>
+
+ <!-- Hidden element required by createNewStream() -->
+ <select id="bc-managed-stream" hidden>
+ <option value="${ms.id}" selected>${esc(ms.title || '')}</option>
+ </select>
`;
`;
- // Restore active tab
- _wsTabSwitch(_wsState.activeTab || 'profile');
+ _glSyncSummaries();
// Async post-render: check device permissions
// Async post-render: check device permissions
// Corner/size selects are derived from the stored {x,y,w} fractions.
// Corner/size selects are derived from the stored {x,y,w} fractions.
@@ -916,25 +877,132 @@ function _wsRenderPanel() {
@@ -916,25 +877,132 @@ function _wsRenderPanel() {
try { const aiv = document.getElementById('bc-ws-aiv'); if (aiv && window.aivRenderSlotSection) window.aivRenderSlotSection(aiv, aiv.dataset.ms); } catch { /* */ }
try { const aiv = document.getElementById('bc-ws-aiv'); if (aiv && window.aivRenderSlotSection) window.aivRenderSlotSection(aiv, aiv.dataset.ms); } catch { /* */ }
}
}
+/* ── The setup's one screen: summaries and chips (broadcast-workspace.js _wsRenderPanel) ── */
+
+/** The icon a slot is known by: the camera, the microphone or the screen in a browser; the encoder otherwise. */
+function _glMethodIcon(method, browserMode) {
+ if (method === 'rtmp') return 'server';
+ if (method === 'whip') return 'satellite-dish';
+ if (method === 'cli' || method === 'jsmpeg') return 'terminal';
+ const m = _WS_BROWSER_MODES.find((x) => x.id === (browserMode || 'camera'));
+ return m ? m.icon : 'video';
+}
+
+function _glHowSummary(method, browserMode) {
+ if (method !== 'browser' && method !== 'webrtc') return _wsMethodLabel(method);
+ const m = _WS_BROWSER_MODES.find((x) => x.id === (browserMode || 'camera'));
+ return `This browser · ${m ? m.label : 'Camera & Mic'}`;
+}
+
+function _glDetailsSummary(ms) {
+ const desc = (document.getElementById('bc-description')?.value ?? ms.description ?? '').trim();
+ const slug = (document.getElementById('bc-slug')?.value ?? ms.slug ?? '').trim();
+ return `${desc ? 'Description set' : 'No description'} · ${slug ? '/' + esc(slug) : 'no short link'}`;
+}
+
+function _glRecSummary(ms) {
+ const vis = { public: 'public', unlisted: 'unlisted', private: 'private' }[ms.default_vod_visibility || 'public'] || 'public';
+ if (ms.slot_vod_recording_enabled === 0) return ms.slot_clip_recording_enabled === 0 ? 'Off' : 'Clips only';
+ return `VODs ${vis}${ms.slot_clip_recording_enabled === 0 ? ' · no clips' : ' · clips on'}`;
+}
+
+function _glWeatherSummary(ms) {
+ if (ms.weather_detail === 'off') return 'Off';
+ return ms.weather_zip ? esc(ms.weather_zip) : 'No place set';
+}
+
+/** Every summary from what the fields hold now, so a change shows in its fold's line at once. */
+function _glSyncSummaries() {
+ const ms = _wsState.selectedMs;
+ if (!ms) return;
+ const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
+ set('gl-details-sum', _glDetailsSummary(ms));
+ const method = ms.streaming_method || 'browser', mode = ms.browser_mode || _wsState.profile?.browserMode || 'camera';
+ set('gl-how-sum', esc(_glHowSummary(method, mode)));
+ const howIcon = document.querySelector('#gl-how > summary > i:first-child');
+ if (howIcon) howIcon.className = `fa-solid fa-${_glMethodIcon(method, mode)}`;
+ set('gl-rec-sum', _glRecSummary(ms));
+ set('gl-weather-sum', _glWeatherSummary(ms));
+ const pc = document.getElementById('bc-ws-powerchat-relay');
+ if (pc) set('gl-pc-sum', pc.checked ? 'Relaying chat' : 'Not relaying');
+ const nsfw = document.getElementById('bc-nsfw');
+ if (nsfw) set('gl-mature-v', nsfw.checked ? 'on' : 'off');
+ const slug = (document.getElementById('bc-slug')?.value ?? ms.slug ?? '').trim().toLowerCase();
+ const user = (typeof currentUser !== 'undefined' && currentUser && currentUser.username) || '';
+ if (user && typeof channelPath === 'function') set('gl-link', esc(`openvibe.live${channelPath(user, slug || ms.id)}`));
+}
+
+/** Restreams in the chip and its fold: the names of those that start with the stream. */
+function _glSyncRestreams() {
+ const on = (_wsRestreamDests || []).filter((d) => d.enabled !== 0 && d.enabled !== false)
+ .map((d) => (_wsRestreamPlatforms[d.platform] || {}).name || d.name || d.platform);
+ if (_wsSlotRsIntegration && _wsSlotRsIntegration.enabled) on.push('RobotStreamer');
+ const chip = document.getElementById('gl-rs-v');
+ const sum = document.getElementById('gl-rs-sum');
+ const names = on.length > 2 ? `${on.slice(0, 2).join(', ')} +${on.length - 2}` : on.join(', ');
+ if (chip) chip.textContent = on.length ? names : 'none';
+ if (sum) sum.textContent = on.length ? `${on.length} on · ${names}` : 'None yet';
+}
+
+/** ✨ Suggest: three titles for the next stream (server/streaming/title-ideas.js); a tap puts one in the title. */
+async function _glSuggestTitles(btn) {
+ const ms = _wsState.selectedMs;
+ const box = document.getElementById('gl-ideas');
+ if (!ms || !box || btn.getAttribute('aria-busy') === 'true') return;
+ const busy = window.OVMark && typeof OVMark.busy === 'function' ? OVMark.busy(btn) : null;
+ btn.setAttribute('aria-busy', 'true');
+ const now = new Date();
+ const weekday = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getDay()];
+ try {
+ const r = await api(`/streams/managed/${ms.id}/title-ideas`, { method: 'POST', body: { weekday, hour: now.getHours(), title: document.getElementById('bc-title')?.value || '' } });
+ const titles = (r && Array.isArray(r.titles)) ? r.titles : [];
+ box.replaceChildren(...titles.map((t) => {
+ const b = document.createElement('button');
+ b.type = 'button'; b.className = 'gl-idea'; b.textContent = t;
+ b.onclick = () => {
+ const input = document.getElementById('bc-title');
+ if (input) { input.value = t; input.focus(); }
+ box.hidden = true;
+ _wsMarkDirty();
+ };
+ return b;
+ }));
+ box.hidden = !titles.length;
+ if (busy) busy.done(titles.length ? 'Ideas' : 'None');
+ } catch (err) {
+ box.replaceChildren(Object.assign(document.createElement('p'), { className: 'gl-ideas-note', textContent: (err && err.message) || 'No ideas right now. Try again in a minute.' }));
+ box.hidden = false;
+ if (busy) busy.fail('Try again');
+ } finally {
+ btn.removeAttribute('aria-busy');
+ }
+}
+
+function _glOpenFold(id) {
+ const d = document.getElementById(id);
+ if (!d) return;
+ d.open = true;
+ d.scrollIntoView({ block: 'start', behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
+}
+
+function _glCopyLink() {
+ const t = document.getElementById('gl-link')?.textContent?.trim();
+ if (!t) return;
+ _wsCopyText(`https://${t.replace(/^https?:\/\//, '')}`, 'Link copied');
+}
+
/* ── Tab switching ───────────────────────────────────────────── */
/* ── Tab switching ───────────────────────────────────────────── */
function _wsTabSwitch(tabId) {
function _wsTabSwitch(tabId) {
- _wsState.activeTab = tabId;
- document.querySelectorAll('.bc-ws-tab').forEach(t =>
- t.classList.toggle('active', t.dataset.wstab === tabId)
- );
- document.querySelectorAll('.bc-ws-tab-panel').forEach(p =>
- p.classList.toggle('active', p.dataset.wstabpanel === tabId)
- );
- // Load history lazily when switching to history tab
- if (tabId === 'history' && _wsState.selectedId) {
- _wsLoadHistory(_wsState.selectedId);
- }
+ const fold = { endpoint: 'gl-endpoint', history: 'gl-history', settings: 'bc-ws-restream-details' }[tabId];
+ if (fold) _glOpenFold(fold);
}
}
/* ── Floating save bar (Discord-style) ───────────────────────── */
/* ── Floating save bar (Discord-style) ───────────────────────── */
function _wsEnsureSaveBar() {
function _wsEnsureSaveBar() {
+ return; // no save bar: changes save as they are made
+ // eslint-disable-next-line no-unreachable
if (document.getElementById('bc-ws-save-bar')) return;
if (document.getElementById('bc-ws-save-bar')) return;
const bar = document.createElement('div');
const bar = document.createElement('div');
bar.className = 'bc-ws-save-bar hidden';
bar.className = 'bc-ws-save-bar hidden';
@@ -1696,13 +1764,26 @@ async function _wsRegenerateKey(managedStreamId) {
@@ -1696,13 +1764,26 @@ async function _wsRegenerateKey(managedStreamId) {
/* ── Dirty state / Manual Save ───────────────────────────────── */
/* ── Dirty state / Manual Save ───────────────────────────────── */
+let _wsAutoSaveTimer = null;
function _wsMarkDirty() {
function _wsMarkDirty() {
_wsState.dirty = true;
_wsState.dirty = true;
- _wsUpdateSaveButton();
+ _wsState.rev = (_wsState.rev || 0) + 1;
_wsSyncQualitySummary();
_wsSyncQualitySummary();
+ _glSyncSummaries();
+ clearTimeout(_wsAutoSaveTimer);
+ _wsAutoSaveTimer = setTimeout(() => { _wsSaveAll(); }, 900);
+}
+
+/** Save now what is waiting (before switching slots or going live). */
+async function _wsFlushSave() {
+ clearTimeout(_wsAutoSaveTimer);
+ if (_wsState.savePromise) await _wsState.savePromise;
+ if (_wsState.dirty) await _wsSaveAll();
}
}
function _wsUpdateSaveButton() {
function _wsUpdateSaveButton() {
+ return; // saving is automatic now (_wsMarkDirty); kept for older callers
+ // eslint-disable-next-line no-unreachable
const bar = document.getElementById('bc-ws-save-bar');
const bar = document.getElementById('bc-ws-save-bar');
const btn = document.getElementById('bc-ws-save-btn');
const btn = document.getElementById('bc-ws-save-btn');
const status = document.getElementById('bc-ws-save-status');
const status = document.getElementById('bc-ws-save-status');
@@ -1731,21 +1812,27 @@ function _wsUpdateSaveButton() {
@@ -1731,21 +1812,27 @@ function _wsUpdateSaveButton() {
function _wsShowSaveStatus(msg, cls) {
function _wsShowSaveStatus(msg, cls) {
const el = document.getElementById('bc-ws-save-status');
const el = document.getElementById('bc-ws-save-status');
- const bar = document.getElementById('bc-ws-save-bar');
- if (el) {
+ if (!el) return;
+ el.className = 'bc-ws-save-status' + (cls ? ' ' + cls : '');
+ if (cls === 'bc-ws-save-error') {
+ el.replaceChildren(document.createTextNode(msg + ' '));
+ const again = document.createElement('button');
+ again.type = 'button'; again.className = 'gl-retry'; again.textContent = 'Try again';
+ again.onclick = () => { _wsState.dirty = true; _wsSaveAll(); };
+ el.append(again);
+ } else {
el.textContent = msg;
el.textContent = msg;
- el.className = 'bc-ws-save-status' + (cls ? ' ' + cls : '');
}
}
- // Show the bar briefly for save/error feedback even when not dirty
- if (bar && msg) bar.style.display = '';
}
}
/** Save all changes (structured fields + profile blob) */
/** Save all changes (structured fields + profile blob) */
async function _wsSaveAll() {
async function _wsSaveAll() {
- if (!_wsState.selectedId || _wsState.saving || !_wsState.dirty) return;
+ if (!_wsState.selectedId || !_wsState.dirty) return;
+ if (_wsState.saving) { _wsState.saveAgain = true; return; }
_wsState.saving = true;
_wsState.saving = true;
- const btn = document.getElementById('bc-ws-save-btn');
- if (btn) btn.disabled = true;
+ const rev = _wsState.rev || 0;
+ let settle;
+ _wsState.savePromise = new Promise((resolve) => { settle = resolve; });
_wsShowSaveStatus('Saving\u2026', 'bc-ws-saving');
_wsShowSaveStatus('Saving\u2026', 'bc-ws-saving');
try {
try {
@@ -1809,20 +1896,28 @@ async function _wsSaveAll() {
@@ -1809,20 +1896,28 @@ async function _wsSaveAll() {
if (idx !== -1) _wsState.managedStreams[idx] = { ..._wsState.managedStreams[idx], ...updated.managed_stream };
if (idx !== -1) _wsState.managedStreams[idx] = { ..._wsState.managedStreams[idx], ...updated.managed_stream };
}
}
_wsState.profile = profileBlob;
_wsState.profile = profileBlob;
- _wsState.dirty = false;
+ // A change made while this was saving stays to save next.
+ if ((_wsState.rev || 0) === rev) _wsState.dirty = false;
_wsRenderSidebar();
_wsRenderSidebar();
- _wsShowSaveStatus('Saved \u2713', 'bc-ws-saved');
- setTimeout(() => { if (!_wsState.dirty) _wsShowSaveStatus(''); }, 2500);
+ _glSyncSummaries();
+ _wsShowSaveStatus('Saved', 'bc-ws-saved');
+ setTimeout(() => { if (!_wsState.dirty && !_wsState.saving) _wsShowSaveStatus(''); }, 2500);
// Update sidebar method display
// Update sidebar method display
const methodToProtocol = { browser: 'webrtc', whip: 'webrtc', cli: 'jsmpeg', rtmp: 'rtmp' };
const methodToProtocol = { browser: 'webrtc', whip: 'webrtc', cli: 'jsmpeg', rtmp: 'rtmp' };
broadcastState.selectedMethod = methodToProtocol[streamFields.streaming_method] || streamFields.protocol;
broadcastState.selectedMethod = methodToProtocol[streamFields.streaming_method] || streamFields.protocol;
} catch (err) {
} catch (err) {
- _wsShowSaveStatus('Save failed: ' + (err?.message || 'unknown error'), 'bc-ws-save-error');
+ _wsShowSaveStatus('Not saved: ' + (err?.message || 'the server did not answer') + '.', 'bc-ws-save-error');
} finally {
} finally {
_wsState.saving = false;
_wsState.saving = false;
- _wsUpdateSaveButton();
+ _wsState.savePromise = null;
+ settle();
+ if (_wsState.dirty && (_wsState.saveAgain || (_wsState.rev || 0) !== rev)) {
+ _wsState.saveAgain = false;
+ clearTimeout(_wsAutoSaveTimer);
+ _wsAutoSaveTimer = setTimeout(() => { _wsSaveAll(); }, 900);
+ }
}
}
}
}
@@ -2156,6 +2251,8 @@ function _wsPipRender() {
@@ -2156,6 +2251,8 @@ function _wsPipRender() {
const box = document.getElementById('bc-ws-pip-list');
const box = document.getElementById('bc-ws-pip-list');
const add = document.getElementById('bc-ws-pip-add');
const add = document.getElementById('bc-ws-pip-add');
if (!st || !box || !add) return;
if (!st || !box || !add) return;
+ const ps = document.getElementById('gl-pip-sum');
+ if (ps) ps.textContent = st.list.length ? `${st.list.length} over this stream` : 'None';
const mine = (_wsState.managedStreams || []);
const mine = (_wsState.managedStreams || []);
const ownIds = new Set(mine.map((m) => Number(m.id)));
const ownIds = new Set(mine.map((m) => Number(m.id)));
box.replaceChildren(...st.list.map((p, i) => {
box.replaceChildren(...st.list.map((p, i) => {
@@ -2234,12 +2331,8 @@ async function goLiveFromWorkspace() {
@@ -2234,12 +2331,8 @@ async function goLiveFromWorkspace() {
return;
return;
}
}
- // Prompt to save if dirty
- if (_wsState.dirty) {
- const action = await _wsConfirmUnsaved('You have unsaved changes. Save before going live?');
- if (action === 'cancel') return;
- if (action === 'save') await _wsSaveAll();
- }
+ // What was just typed (the title above all) is saved before the stream starts.
+ await _wsFlushSave();
const p = _wsState.profile;
const p = _wsState.profile;
const ms = _wsState.selectedMs;
const ms = _wsState.selectedMs;
@@ -2346,12 +2439,16 @@ async function _wsLoadHistory(managedStreamId) {
@@ -2346,12 +2439,16 @@ async function _wsLoadHistory(managedStreamId) {
_wsRenderHistory(_wsState.history);
_wsRenderHistory(_wsState.history);
} catch {
} catch {
list.innerHTML = '<p class="muted">Could not load session history.</p>';
list.innerHTML = '<p class="muted">Could not load session history.</p>';
+ const hs = document.getElementById('gl-hist-sum');
+ if (hs) hs.textContent = 'Could not load';
}
}
}
}
function _wsRenderHistory(sessions) {
function _wsRenderHistory(sessions) {
const list = document.getElementById('bc-ws-history-list');
const list = document.getElementById('bc-ws-history-list');
if (!list) return;
if (!list) return;
+ const hs = document.getElementById('gl-hist-sum');
+ if (hs) hs.textContent = sessions.length ? `${sessions.length} recent` : 'None yet';
if (!sessions.length) {
if (!sessions.length) {
list.innerHTML = '<p class="muted" style="padding:8px 0">No past sessions yet. Go live to start your first session.</p>';
list.innerHTML = '<p class="muted" style="padding:8px 0">No past sessions yet. Go live to start your first session.</p>';
@@ -2509,9 +2606,10 @@ function _wsRenderRestreamList(container) {
@@ -2509,9 +2606,10 @@ function _wsRenderRestreamList(container) {
if (!container) return;
if (!container) return;
const rsCard = _wsRenderSlotRsCard();
const rsCard = _wsRenderSlotRsCard();
+ _glSyncRestreams();
if (_wsRestreamDests.length === 0 && !rsCard) {
if (_wsRestreamDests.length === 0 && !rsCard) {
- container.innerHTML = '<p class="muted" style="font-size:0.82rem">No restream destinations configured for this stream slot.</p>';
+ container.innerHTML = '<p class="muted" style="font-size:0.82rem">Nowhere else yet. Add Twitch, YouTube, Kick or any RTMP address, and this slot goes there too each time you go live.</p>';
return;
return;
}
}
@@ -1505,6 +1505,9 @@ router.get('/channel/:username/resolve/:ref', optionalAuth, async (req, res) =>
@@ -1505,6 +1505,9 @@ router.get('/channel/:username/resolve/:ref', optionalAuth, async (req, res) =>
// ── Managed Stream CRUD ──────────────────────────────────────
// ── Managed Stream CRUD ──────────────────────────────────────
+// ✨ Suggest on the go-live card: three titles for the slot's next stream (title-ideas.js).
+require('./title-ideas').mount(router, { requireAuth, db });
+
// Get past stream sessions for a managed stream (workspace history panel)
// Get past stream sessions for a managed stream (workspace history panel)
router.get('/managed/:managedStreamId/history', requireAuth, async (req, res) => {
router.get('/managed/:managedStreamId/history', requireAuth, async (req, res) => {
const managedStreamId = parseInt(req.params.managedStreamId);
const managedStreamId = parseInt(req.params.managedStreamId);
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * ✨ Suggest, on the go-live card (golive-setup redesign, 2026-11-02): three titles for a slot's next stream, written
+ * from the titles and categories of its last streams and the streamer's own day and hour (the browser sends them; the
+ * server does not know the streamer's time zone). One short AI call, attributed to the streamer (Live's per-streamer
+ * AI budget), and a few a minute at most. The streamer picks one or ignores them; nothing is stored.
+ *
+ * POST /api/streams/managed/:managedStreamId/title-ideas { weekday?, hour? } → { titles: [..3] }
+ */
+
+const MAX_TITLES = 3;
+const TITLE_MAX = 80;
+const WINDOW_MS = 10 * 60 * 1000;
+const PER_WINDOW = 12;
+const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+
+const SYSTEM = [
+ 'You name live streams. Write titles a streamer would choose for their next stream on this slot.',
+ 'Keep their own way of naming streams: their words, their length, their language.',
+ 'Each title is plain words, under 60 characters: no hashtags, no emoji, no quotation marks, no "LIVE", no clickbait',
+ 'and no made-up facts (no guests, numbers or events the past titles do not show).',
+ 'The three titles differ from each other and from the current title.',
+].join(' ');
+
+const _uses = new Map(); // userId → timestamps in the window
+
+/** True when this person may ask again now (and counts the ask). */
+function allow(userId, now = Date.now()) {
+ const list = (_uses.get(userId) || []).filter((t) => now - t < WINDOW_MS);
+ if (list.length >= PER_WINDOW) { _uses.set(userId, list); return false; }
+ list.push(now);
+ _uses.set(userId, list);
+ return true;
+}
+
+function partOfDay(hour) {
+ if (!Number.isInteger(hour) || hour < 0 || hour > 23) return null;
+ if (hour < 5) return 'late at night';
+ if (hour < 12) return 'in the morning';
+ if (hour < 17) return 'in the afternoon';
+ if (hour < 21) return 'in the evening';
+ return 'at night';
+}
+
+/** A title as a streamer would type it: one line, no wrapping quotes or hashtags, not too long. */
+function clean(t) {
+ let s = String(t == null ? '' : t).replace(/\s+/g, ' ').trim();
+ s = s.replace(/^\d+[.)]\s*/, '').replace(/\s#\w+/g, '').trim().replace(/^["'“”‘’«»]+|["'“”‘’«»]+$/g, '').trim();
+ if (s.length > TITLE_MAX) s = s.slice(0, TITLE_MAX).replace(/\s+\S*$/, '').trim();
+ return s;
+}
+
+function promptFor({ slot, sessions, weekday, hour }) {
+ const recent = sessions.slice(0, 8).map((s) => {
+ const when = s.started_at ? new Date(s.started_at) : null;
+ const day = when && !Number.isNaN(when.getTime()) ? DAYS[(when.getUTCDay() + 6) % 7] : '';
+ return `- ${clean(s.title) || '(untitled)'}${s.category ? ` (${String(s.category).slice(0, 40)})` : ''}${day ? `, a ${day}` : ''}`;
+ });
+ const when = [DAYS.includes(weekday) ? `It is ${weekday}` : '', partOfDay(hour) || ''].filter(Boolean).join(' ');
+ return [
+ `Slot: ${clean(slot.title) || '(untitled)'}${slot.description ? ` — ${String(slot.description).replace(/\s+/g, ' ').slice(0, 200)}` : ''}`,
+ recent.length ? `Their last streams on this slot, newest first:\n${recent.join('\n')}` : 'No past streams on this slot yet.',
+ when ? `${when} for the streamer.` : '',
+ `Current title: ${clean(slot.title) || '(none)'}`,
+ `Write ${MAX_TITLES} titles for the next stream.`,
+ ].filter(Boolean).join('\n');
+}
+
+/**
+ * The titles, or [] when the AI has nothing usable. deps: { db, llm, wordFilter } for tests.
+ */
+async function ideas({ slot, userId, weekday, hour }, deps = {}) {
+ const db = deps.db || require('../db/database');
+ const llm = deps.llm || require('../ai/llm');
+ let sessions = [];
+ try { sessions = await db.getStreamHistoryByManagedStream(slot.id, userId, 8); } catch { sessions = []; }
+ const r = await llm.complete({
+ role: 'chat', system: SYSTEM, user: promptFor({ slot, sessions, weekday, hour }),
+ json: { name: 'titles', strict: true, schema: { type: 'object', additionalProperties: false, required: ['titles'], properties: { titles: { type: 'array', items: { type: 'string' } } } } },
+ maxTokens: 180, temperature: 0.8, kind: 'golive_title_ideas', source: 'golive-suggest', ownerUserId: userId, timeoutMs: 20000,
+ }).catch(() => null);
+ if (!r) return [];
+ let raw = r.json && Array.isArray(r.json.titles) ? r.json.titles : null;
+ if (!raw) {
+ try { const j = JSON.parse(r.text); raw = Array.isArray(j.titles) ? j.titles : null; } catch { raw = String(r.text || '').split('\n'); }
+ }
+ let filter = deps.wordFilter;
+ if (!filter) { try { filter = require('../chat/word-filter'); } catch { filter = null; } }
+ const seen = new Set([clean(slot.title).toLowerCase()]);
+ const out = [];
+ for (const t of raw || []) {
+ const c = clean(t);
+ if (c.length < 3 || seen.has(c.toLowerCase())) continue;
+ if (filter && filter.check && !filter.check(c).safe) continue;
+ seen.add(c.toLowerCase());
+ out.push(c);
+ if (out.length === MAX_TITLES) break;
+ }
+ return out;
+}
+
+function mount(router, { requireAuth, db }) {
+ router.post('/managed/:managedStreamId/title-ideas', requireAuth, async (req, res) => {
+ const id = parseInt(req.params.managedStreamId, 10);
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid ID' });
+ try {
+ const slot = await db.getManagedStreamById(id);
+ if (!slot) return res.status(404).json({ error: 'Managed stream not found' });
+ if (slot.user_id !== req.user.id) return res.status(403).json({ error: 'Not your managed stream' });
+ if (!allow(req.user.id)) return res.status(429).json({ error: 'That is a lot of ideas: try again in a few minutes.' });
+ const body = req.body || {};
+ const hour = Number.isInteger(body.hour) ? body.hour : null;
+ const weekday = typeof body.weekday === 'string' ? body.weekday : null;
+ // The title being typed counts as the current one, so the ideas differ from it.
+ const typed = typeof body.title === 'string' ? body.title.slice(0, 200) : null;
+ const titles = await ideas({ slot: { ...slot, title: typed != null ? typed : slot.title }, userId: req.user.id, weekday, hour });
+ res.set('Cache-Control', 'no-store');
+ if (!titles.length) return res.status(503).json({ error: 'No ideas right now. Try again in a minute.' });
+ res.json({ titles });
+ } catch (err) {
+ console.error('[TitleIdeas]', err.message);
+ res.status(500).json({ error: 'No ideas right now. Try again in a minute.' });
+ }
+ });
+}
+
+module.exports = { ideas, mount, clean, promptFor, partOfDay, allow, _uses, MAX_TITLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+
+/**
+ * The go-live setup is one screen (owner, 2026-10-31: "this layout ain't too great… more intelligent… more modular"):
+ * the slots are pills, the ready card comes first (title, chips, devices, what viewers get, Go live), and every other
+ * setting is a fold whose line says what it holds. Nothing the old Stream / Settings / Endpoint / History tabs had is
+ * lost: every field they rendered is still rendered, by the same id. Changes save as they are made.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ws = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-workspace.js'), 'utf8');
+const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'broadcast.html'), 'utf8');
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'broadcast.css'), 'utf8');
+const start = ws.indexOf('function _wsRenderPanel()');
+const panel = ws.slice(start, ws.indexOf('\n}\n', start));
+
+// Every field of the old tabs, by id: Stream (title … quality), Go live (devices, button), Settings (PiP, restreams,
+// overlay, PowerChat, AI viewers, recording, weather), Endpoint (key, method endpoint) and History.
+const FIELDS = [
+ 'bc-title', 'bc-description', 'bc-nsfw', 'bc-slug',
+ 'bc-ws-method-picker', 'bc-ws-browser-mode-picker', 'bc-ws-mic-image',
+ 'bc-ws-screen-mic', 'bc-ws-screen-mic-select', 'bc-ws-screen-sysaudio', 'bc-ws-screen-cam', 'bc-ws-screen-cam-select',
+ 'bc-ws-screen-cam-mode', 'bc-ws-screen-pip-pos', 'bc-ws-screen-pip-size',
+ 'bc-ws-screen-resolution', 'bc-ws-screen-fps', 'bc-ws-screen-bitrate',
+ 'bc-ws-resolution', 'bc-ws-fps', 'bc-ws-bitrate', 'bc-ws-battery-saver',
+ 'bc-create-camera', 'bc-create-audio', 'bc-ws-golive-btn', 'bc-ws-golive-section',
+ 'bc-ws-pip-list', 'bc-ws-pip-add', 'bc-ws-restream-list', 'bc-ws-overlay-url',
+ 'bc-ws-powerchat-relay', 'bc-ws-powerchat-rs-views', 'bc-ws-aiv',
+ 'bc-ws-vod-recording', 'bc-ws-clip-recording', 'bc-ws-clip-notify', 'bc-ws-vod-visibility', 'bc-ws-clip-visibility',
+ 'bc-ws-weather-zip', 'bc-ws-weather-detail', 'bc-ws-weather-location',
+ 'bc-ws-stream-key', 'bc-ws-method-endpoint', 'bc-ws-history-list', 'bc-managed-stream',
+];
+for (const id of FIELDS) assert.ok(panel.includes(`id="${id}"`), `the setup still renders #${id}`);
+// The endpoint block is one template, placed in the card (an encoder's slot) or in a fold (a browser slot): never twice.
+assert.strictEqual((panel.match(/id="bc-ws-stream-key"/g) || []).length, 1, 'the key field is written once');
+assert.ok(/const endpoint = `/.test(panel) && /\$\{endpoint\}/.test(panel), 'the endpoint goes where the method needs it');
+
+// One screen: no tabs; the card first, then the folds.
+assert.ok(!panel.includes('data-wstab') && !panel.includes('bc-ws-tab-panel'), 'no tabs');
+const card = panel.indexOf('<section class="gl-card');
+const folds = panel.indexOf('<div class="gl-folds">');
+assert.ok(card > 0 && folds > card, 'the ready card, then the folds');
+assert.ok(panel.indexOf('id="bc-title"') > card && panel.indexOf('id="bc-title"') < folds, 'the title is in the card');
+assert.ok(panel.indexOf('${_wsPreflight(browserMode)}') > card && panel.indexOf('${_wsPreflight(browserMode)}') < folds, 'what viewers get is in the card');
+
+// Every fold's line says what it holds (L25: a fold shows its count or summary).
+const foldCount = (panel.match(/<details class="gl-fold/g) || []).length;
+assert.ok(foldCount >= 12, `the settings are folds (${foldCount})`);
+for (const m of panel.matchAll(/<details class="gl-fold[^>]*>\s*<summary>([\s\S]*?)<\/summary>/g)) {
+ assert.ok(/class="bc-sum-val"/.test(m[1]), `a fold's line has a value: ${m[1].slice(0, 120)}`);
+}
+for (const id of ['gl-details-sum', 'gl-how-sum', 'gl-rs-sum', 'gl-pip-sum', 'gl-rec-sum', 'gl-weather-sum', 'gl-hist-sum']) {
+ assert.ok(panel.includes(`id="${id}"`), `#${id} is in the panel`);
+ assert.ok(ws.includes(`'${id}'`), `#${id} is kept current`);
+}
+
+// The chips say what is set and act in place.
+for (const chip of ['id="gl-mature-v"', 'id="gl-link"', 'id="gl-rs-v"', '_wsOpenPopoutChat()']) assert.ok(panel.includes(chip), chip);
+
+// Saving as you type: no save bar and no "unsaved changes" question; switching slots or going live saves first.
+assert.ok(/function _wsMarkDirty\(\)[\s\S]{0,400}_wsAutoSaveTimer = setTimeout/.test(ws), 'a change saves after a short pause');
+const select = ws.slice(ws.indexOf('async function wsSelectStream('), ws.indexOf('async function _wsSelectStream('));
+assert.ok(select.includes('await _wsFlushSave()') && !select.includes('_wsConfirmUnsaved'), 'switching slots saves, never asks');
+const golive = ws.slice(ws.indexOf('async function goLiveFromWorkspace('), ws.indexOf('function updateWorkspaceLiveStatus('));
+assert.ok(golive.includes('await _wsFlushSave()') && !golive.includes('_wsConfirmUnsaved'), 'going live saves first, never asks');
+assert.ok(/if \(\(_wsState\.rev \|\| 0\) === rev\) _wsState\.dirty = false/.test(ws), 'a change made while saving is not lost');
+
+// The slots are pills, with New slot at the end; nothing spins while the page loads.
+assert.ok(/class="gl-slot\$\{isSelected/.test(ws) && ws.includes('gl-slot--add'), 'slot pills');
+assert.ok(frag.includes('class="bc-ws-sidebar gl-slots"') && !frag.includes('bc-ws-sidebar-hd'), 'the fragment has the pill row');
+assert.ok(!panel.includes('fa-spin') && !frag.slice(frag.indexOf('bc-ws-list'), frag.indexOf('bc-ws-main')).includes('fa-spin'), 'no spinning ring');
+
+// Wide screens: two columns, the card sticky; phones: 44 px targets.
+assert.ok(/@container bcws \(min-width: 900px\) \{\s*\.gl \{ grid-template-columns/.test(css), 'two columns on a wide screen');
+assert.ok(/@media \(pointer: coarse\) \{ \.gl-chip \{ min-height: 44px; \} \}/.test(css), 'chips are thumb-sized on touch');
+
+console.log('golive-setup: all passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+
+/**
+ * ✨ Suggest (server/streaming/title-ideas.js): three clean titles from the slot's last streams and the streamer's own
+ * hour, never the current one again, never a filtered word; a short AI call attributed to the streamer; a few a minute.
+ */
+const assert = require('assert');
+const ti = require('../server/streaming/title-ideas');
+
+(async () => {
+ const slot = { id: 11, title: 'Phone Home Stream', description: 'Walking around town', user_id: 1 };
+ const sessions = [
+ { title: 'Night walk downtown', category: 'IRL', started_at: '2026-10-31T23:00:00Z' },
+ { title: 'Market day', category: 'IRL', started_at: '2026-10-30T15:00:00Z' },
+ ];
+ const db = { async getStreamHistoryByManagedStream(id, userId, limit) { assert.deepStrictEqual([id, userId, limit], [11, 1, 8]); return sessions; } };
+ let call = null;
+ const llm = { async complete(o) { call = o; return { json: { titles: ['"Night walk by the river"', 'Phone Home Stream', '1. Sunday market loop #irl', 'badword here', 'Coffee and a long walk', 'One more'] } }; } };
+ const wordFilter = { check: (t) => ({ safe: !/badword/.test(t) }) };
+
+ const titles = await ti.ideas({ slot, userId: 1, weekday: 'Sunday', hour: 21 }, { db, llm, wordFilter });
+ assert.deepStrictEqual(titles, ['Night walk by the river', 'Sunday market loop', 'Coffee and a long walk'], 'cleaned, the current title and a filtered one left out, three at most');
+ assert.strictEqual(call.ownerUserId, 1, 'the spend is the streamer\'s');
+ assert.strictEqual(call.kind, 'golive_title_ideas');
+ assert.ok(call.maxTokens <= 200, 'a short call');
+ assert.ok(call.user.includes('- Night walk downtown (IRL)'), 'the last streams are in the prompt');
+ assert.ok(call.user.includes('It is Sunday at night for the streamer.'), 'the streamer\'s own day and hour');
+ assert.ok(call.json && call.json.schema.properties.titles, 'a JSON answer');
+
+ // A plain-text answer still works; nothing usable is [].
+ const textLlm = { async complete() { return { text: 'Walk one\nWalk two' }; } };
+ assert.deepStrictEqual(await ti.ideas({ slot, userId: 1 }, { db, llm: textLlm, wordFilter }), ['Walk one', 'Walk two']);
+ assert.deepStrictEqual(await ti.ideas({ slot, userId: 1 }, { db, llm: { async complete() { return null; } }, wordFilter }), []);
+
+ assert.strictEqual(ti.clean(' "A very long title" #tag '), 'A very long title');
+ assert.strictEqual(ti.partOfDay(8), 'in the morning');
+ assert.strictEqual(ti.partOfDay(99), null);
+
+ // A few a minute: the 13th ask in ten minutes waits, and the window frees.
+ ti._uses.clear();
+ const asks = Array.from({ length: 12 }, (_, i) => ti.allow(7, 1000 + i));
+ assert.ok(asks.every(Boolean), 'twelve asks pass');
+ assert.ok(!ti.allow(7, 2000), 'the limit holds');
+ assert.ok(ti.allow(7, 1000 + 11 * 60 * 1000), 'and frees after the window');
+ console.log('golive-title-ideas: all passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -108,31 +108,19 @@ assert.ok(/muted: pip\.muted !== false/.test(pip),
@@ -108,31 +108,19 @@ assert.ok(/muted: pip\.muted !== false/.test(pip),
assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');
assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');
console.log('OK I: geometry persists, hide is recoverable, audio defaults to muted, offline degrades gracefully');
console.log('OK I: geometry persists, hide is recoverable, audio defaults to muted, offline degrades gracefully');
-// ── J: workspace placement — Settings owns the control profile and the PiP section ───
+// ── J: workspace placement — the PiP section is its own fold on the one-screen setup ───
{
{
const ws = read('public/js/broadcast-workspace.js');
const ws = read('public/js/broadcast-workspace.js');
- const at = (name) => ws.indexOf(`data-wstabpanel="${name}"`);
- const [prof, settings, endpoint] = [at('profile'), at('settings'), at('endpoint')];
- assert.ok(prof > 0 && settings > prof && endpoint > settings, 'expected profile/settings/endpoint panels in order');
- const streamPanel = ws.slice(prof, settings);
- const settingsPanel = ws.slice(settings, endpoint);
-
// Live's robot controls are gone (robots are OpenVibe.Bot's): no control profile select anywhere.
// Live's robot controls are gone (robots are OpenVibe.Bot's): no control profile select anywhere.
assert.ok(!ws.includes('id="bc-control-config"'), 'no control profile select in the workspace');
assert.ok(!ws.includes('id="bc-control-config"'), 'no control profile select in the workspace');
- assert.ok(settingsPanel.includes('id="bc-ws-pip-list"') && settingsPanel.includes('id="bc-ws-pip-add"'),
- 'the PIP list must render in the Settings tab');
-
- // It is its own collapsible section, matching the other Settings groups.
- for (const label of ['Picture-in-picture']) {
- // Anchor on the SUMMARY text: an HTML comment carries the same label just above
- // the <details>, and matching that would look in the wrong place entirely.
- const idx = settingsPanel.indexOf(`${label}</summary>`);
- assert.ok(idx > 0, `Settings must have a "${label}" collapsible section`);
- const before = settingsPanel.slice(Math.max(0, idx - 400), idx);
- assert.ok(before.includes('<details class="bc-ws-slot-settings" id="bc-ws-pip-details">'),
- `"${label}" must be a collapsible <details> section like the rest of Settings`);
- }
- console.log('OK J: the PiP camera is a collapsible section under Settings, and there is no control profile');
+ // No tabs since the one-screen setup (2026-11-02): the PIP list is a fold of its own, with its count in its line.
+ assert.ok(!ws.includes('data-wstabpanel='), 'no tab panels');
+ const at = ws.indexOf('<details class="gl-fold" id="bc-ws-pip-details">');
+ assert.ok(at > 0, 'Picture-in-picture is a fold');
+ const fold = ws.slice(at, ws.indexOf('</details>', at));
+ assert.ok(fold.includes('<span class="bc-sum-title">Picture-in-picture</span>') && fold.includes('id="gl-pip-sum"'), 'its line says how many');
+ assert.ok(fold.includes('id="bc-ws-pip-list"') && fold.includes('id="bc-ws-pip-add"'), 'the PIP list renders in its fold');
+ console.log('OK J: the PiP camera is its own fold, and there is no control profile');
}
}
// ── K: PiP starting geometry round-trips through corner/size ─────────────────────────
// ── K: PiP starting geometry round-trips through corner/size ─────────────────────────