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)

OP OpenVibers committed 11 days ago · 795304aa42 · 10 files, +860 −319

* Go live setup is one screen: the ready card first, every other setting a fold that says what it holds, saved as you type

* Suggest on the go-live card: three titles from the slot's last streams and the streamer's own hour

* docs/streams-api.md: everything the Go live screen does, as API calls

* docs/streams-api: the SDK calls, and starting a session from a slot

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 531d37af..9dbd0f4b 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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 system
ddiff --git a/docs/streams-api.md b/docs/streams-api.md
nnew file mode 100644
iindex 00000000..fe831647
---- /dev/null
++++ b/docs/streams-api.md
@@@ -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. |
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 054d8b5e..d623adb3 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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; }
++}
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 56638ea5..df3b0c2d 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -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 slot
workspace 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&hellip;</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>
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 66781b87..6bbe2f06 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -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 onclick
async 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 profile
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) ── -->
<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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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-unreachable
if (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-unreachable
const 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 display
const 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;
}
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 8a9ed697..ace30162 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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);
ddiff --git a/server/streaming/title-ideas.js b/server/streaming/title-ideas.js
nnew file mode 100644
iindex 00000000..4ef79dcd
---- /dev/null
++++ b/server/streaming/title-ideas.js
@@@ -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 };
ddiff --git a/test/golive-setup.test.js b/test/golive-setup.test.js
nnew file mode 100644
iindex 00000000..c370264c
---- /dev/null
++++ b/test/golive-setup.test.js
@@@ -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');
ddiff --git a/test/golive-title-ideas.test.js b/test/golive-title-ideas.test.js
nnew file mode 100644
iindex 00000000..1e14cb6a
---- /dev/null
++++ b/test/golive-title-ideas.test.js
@@@ -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); });
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
iindex 2dbad814..14720e74 100644
---- a/test/pip-camera-slot.test.js
++++ b/test/pip-camera-slot.test.js
@@@ -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 ─────────────────────────