openvibers/openvibe.live Commits

Broadcast: a live studio on phones (full-screen camera, one-tap flip, mute, camera off, light, chat over the picture, clip, a More sheet, End asks first); the camera buttons no longer vanish when the live view shows before the camera; live controls in plain words (#158)

OP OpenVibers committed 28 days ago · f6d6219e90 · 9 files, +847 −64

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 327f5bf9..763c391e 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -46,15 +46,28 @@ OpenVibe.Live supports four live broadcast methods, each suited to different use
### Live Controls
When streaming via WebRTC browser, you have access to:
- **Stop Stream** — End the broadcast
--- **Switch Cam** — Change camera device
--- **Screen Share** — Share screen/window/tab
--- **Cam PiP** — Picture-in-Picture camera overlay on screen share
--- **Mic** — Toggle microphone
--- **Stats** — Show bitrate, FPS, resolution, codec overlay
--- **Preview** — Toggle self-preview on/off (saves CPU)
--- **Viewer** — Open a popup showing exactly what viewers see (real mediasoup consumer path)
++- **Mute** / **Camera off** — Silence the microphone or black out the camera without ending the stream (the track is
++ disabled; the choice holds when the camera is switched or a track recovers)
++- **Flip camera** — Front ↔ back in one tap (shown when the device has more than one camera)
++- **Choose camera** — Pick any camera from a list
++- **Share screen** — Share screen/window/tab
++- **Camera in corner** — Picture-in-picture camera over a shared screen
++- **Mic** — Add or remove the microphone while sharing a screen
++- **Stream health** — Show bitrate, FPS, resolution, codec overlay
++- **Test chat voice** / **Skip spoken message** — Hear how chat is read aloud; stop the current message and clear the queue
++- **Hide preview** — Hide your own preview (you stay live; saves CPU and battery)
++- **Watch as viewer** — Open your channel the way viewers see it (real mediasoup consumer path)
- **Clip** — Create a clip from the live stream
--- **Media** — Media request PiP player
++- **Media requests** — Media request PiP player
++
++### The live studio on a phone
++On a phone (narrower than 760 px, or a phone turned sideways), a browser stream opens the live studio
++(`public/js/broadcast-studio.js`): the camera fills the screen, the stream chat sits over the bottom of the picture,
++and the controls sit on top. The top bar has LIVE, time live, people watching, restream status and connection, with
++**End** (it asks first). The right-hand rail has Flip, Mute, Camera, Light (the torch, when the camera has one), Chat,
++Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, choose camera, screen, media requests, a
++one-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classic
++page, with a button back). The studio drives the same functions as the page and reads its counters.
### Stream Chat
The broadcast page includes an embedded chat sidebar with:
ddiff --git a/docs/go-live-in-your-browser.md b/docs/go-live-in-your-browser.md
iindex ce0dfb84..64fc9f33 100644
---- a/docs/go-live-in-your-browser.md
++++ b/docs/go-live-in-your-browser.md
@@@ -23,12 +23,22 @@ You don't need any of the following:
## What you can do while live
--- **Switch cameras:** front and back on a phone, or any connected webcam.
--- **Share your screen,** a window or a tab. You can also show your camera as a picture-in-picture over it.
++- **Flip cameras:** front and back on a phone in one tap, or choose any connected webcam.
++- **Share your screen,** a window or a tab. You can also show your camera in a corner over it.
- **Mute** your microphone or turn off your camera without ending the stream.
- **Watch your stream's health:** bitrate, frame rate and resolution.
- **Chat with viewers** on the same page. Your stream can be kept as a VOD, and people can clip it.
++**On a phone,** going live turns the screen into a live studio, the way phone streaming apps work:
++
++- The camera fills the screen, and chat runs over the bottom of the picture, where you can read and answer it.
++- The top shows LIVE, how long you have been live, how many people are watching, your restreams and your connection.
++- The buttons on the right are Flip (front and back camera), Mute, Camera, Light (a back camera's flash), Chat (hide or
++ show it), Clip (the last 30 seconds) and More.
++- **More** opens share, watch as a viewer, choose any camera, media requests, a one-minute clip, chat read aloud,
++ your restreams, stream health, all settings and the full page.
++- **End** asks before it ends the stream.
++
Your stream reaches viewers over **WebRTC**, with less than a second of delay. That is fast enough for a real conversation with chat, and for viewers to control a robot or camera in real time.
## When to use OBS instead
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 8cb42038..a55111e9 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2452,3 +2452,197 @@
.bc-ws-profile-split > .bc-ws-col--method .bc-method-picker-sm { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.bc-ws-profile-split > .bc-ws-col--details .bc-switch { margin-bottom: 14px; }
}
++
++/* ── Live controls, plainer everywhere ──
++ The connection badge says nothing while all is well (it used to print "WEBRTC"); the LIVE badge's dot pulses,
++ not the whole badge; the RobotStreamer detail line wraps instead of running off the screen. */
++.bc-connection-status.is-quiet { display: none; }
++.bc-live-badge { animation: none; }
++.bc-live-badge i { animation: pulse-live 1.5s infinite; }
++.bc-rs-control .bc-restream-detail { flex-basis: 100%; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
++@media (prefers-reduced-motion: reduce) { .bc-live-badge i { animation: none; } }
++/* On a phone's full page the live controls are a grid of tiles (icon over a short label), not a column of thirteen
++ full-width bars; ending the stream keeps its own full-width row. */
++@media (max-width: 768px) {
++ .bc-ctrl-buttons { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
++ .bc-ctrl-btn {
++ flex-direction: column; justify-content: center; gap: 5px; min-height: 66px; padding: 10px 4px;
++ font-size: 0.78rem; line-height: 1.2; text-align: center; box-shadow: none;
++ }
++ .bc-ctrl-btn i { font-size: 1.05rem; }
++ .bc-ctrl-btn-danger { grid-column: 1 / -1; flex-direction: row; min-height: 48px; font-size: 0.95rem; }
++}
++
++/* ── The live studio on a phone (public/js/broadcast-studio.js) ──
++ body.bc-studio-on: the camera fills the screen, the stream chat sits over its lower part, the studio's own
++ controls (#bc-studio) sit on top, and the page behind stays as it was for "Full page". */
++body.bc-studio-on { overflow: hidden; }
++body.bc-studio-on .bc-video-container {
++ position: fixed; inset: 0; z-index: 1200; margin: 0; width: 100vw; height: 100vh; height: 100dvh;
++ min-height: 0 !important; max-height: none !important; border: 0; border-radius: 0; background: #000; aspect-ratio: auto;
++}
++body.bc-studio-on .bc-video-container video { width: 100%; height: 100%; max-height: none !important; object-fit: contain; }
++/* A vertical camera on a vertical screen fills it, the way phone camera apps show it. */
++body.bc-studio-on .bc-video-container.bc-vertical-preview video { object-fit: cover; }
++body.bc-studio-on #bc-video-preview.bcs-mirror { transform: scaleX(-1); }
++body.bc-studio-on .bc-video-container .bc-live-badge,
++body.bc-studio-on .bc-video-container .bc-connection-status,
++body.bc-studio-on .bc-video-container .bc-video-placeholder { display: none !important; }
++body.bc-studio-on .bc-stats-overlay { top: calc(58px + env(safe-area-inset-top, 0px)); left: 12px; z-index: 1205; }
++body.bc-studio-on .messenger-toggle,
++body.bc-studio-on .bc-mobile-chat-fab,
++body.bc-studio-on #navbar-mount,
++body.bc-studio-on .navbar { display: none !important; }
++/* "Reconnecting…" stays visible, just above the chat instead of over its input. */
++body.bc-studio-on .ov-conn-pill { left: calc(50% - 38px); bottom: calc(min(40vh, 340px) + 10px + env(safe-area-inset-bottom, 0px)); z-index: 1290; }
++
++/* The stream chat over the picture: newest at the bottom, fading out upward, input at the very bottom. */
++body.bc-studio-on #bc-chat-sidebar {
++ display: flex !important; flex-direction: column; position: fixed; z-index: 1210;
++ left: 0; right: 76px; bottom: 0; top: auto; width: auto; max-width: none;
++ height: min(40vh, 340px); height: min(40dvh, 340px); max-height: none;
++ padding: 0 0 env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
++ background: none; border: 0; border-radius: 0; box-shadow: none; transform: none; transition: none;
++}
++body.bc-studio-on #bc-chat-sidebar::before {
++ content: ''; position: absolute; left: 0; right: -76px; bottom: 0; height: 130%; z-index: -1; pointer-events: none;
++ background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.6));
++}
++body.bc-studio-on #bc-chat-sidebar .chat-header,
++body.bc-studio-on #bc-chat-sidebar .chat-users-panel,
++body.bc-studio-on #bc-chat-sidebar .chat-tools-row { display: none !important; }
++body.bc-studio-on #bc-chat-messages {
++ flex: 1; min-height: 0; background: none; padding: 0 10px 4px; overscroll-behavior: contain;
++ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 28%); mask-image: linear-gradient(to bottom, transparent, #000 28%);
++}
++body.bc-studio-on #bc-chat-messages .chat-msg {
++ width: fit-content; max-width: 100%; margin: 3px 0; padding: 4px 9px; border-radius: 12px;
++ background: rgba(0,0,0,0.38); color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.85);
++}
++body.bc-studio-on #bc-chat-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }
++body.bc-studio-on #bc-chat-input {
++ background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
++}
++body.bc-studio-on #bc-chat-input::placeholder { color: rgba(255,255,255,0.72); }
++body.bc-studio-on.bc-studio-chat-off #bc-chat-sidebar { display: none !important; }
++body.bc-studio-on #bc-media-pip {
++ position: fixed; z-index: 1215; top: calc(58px + env(safe-area-inset-top, 0px)); left: 10px; right: 84px;
++ max-height: 46dvh; overflow: auto;
++}
++body.bc-studio-on .bc-camera-sheet { z-index: 1400; }
++body.bc-studio-on .bc-disconnect-alert { z-index: 1450; }
++body.bc-studio-on .toast-container { z-index: 1500; }
++
++.bcs { position: fixed; inset: 0; z-index: 1220; pointer-events: none; color: #fff; }
++.bcs button { font-family: inherit; color: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
++.bcs [hidden] { display: none !important; }
++.bcs button:focus-visible, .bcs-sheet button:focus-visible, .bcs-return:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
++.bcs-top {
++ position: absolute; top: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;
++ padding: calc(10px + env(safe-area-inset-top, 0px)) max(12px, env(safe-area-inset-right, 0px)) 28px max(12px, env(safe-area-inset-left, 0px));
++ background: linear-gradient(rgba(0,0,0,0.55), transparent);
++}
++.bcs-top-l, .bcs-top-r { display: flex; align-items: center; gap: 6px; pointer-events: auto; }
++.bcs-top-l { flex-wrap: wrap; min-width: 0; }
++.bcs-top-r { flex: none; }
++.bcs-live {
++ display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px;
++ background: #e11d48; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.06em;
++}
++.bcs-live-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: bcs-pulse 1.6s ease-in-out infinite; }
++@keyframes bcs-pulse { 50% { opacity: 0.35; } }
++.bcs-pill {
++ display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px;
++ background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.16); font-size: 0.82rem; font-weight: 600;
++ font-variant-numeric: tabular-nums; white-space: nowrap;
++}
++.bcs-pill b { font-weight: 700; }
++.bcs-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
++.bcs-pill.is-ok .bcs-dot, .bcs-restream.is-ok::before { background: #22c55e; }
++.bcs-pill.is-warn .bcs-dot, .bcs-restream.is-warn::before { background: #f59e0b; }
++.bcs-pill.is-bad .bcs-dot, .bcs-restream.is-bad::before { background: #ef4444; }
++.bcs-restream::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
++.bcs-pill.is-bad { border-color: rgba(239,68,68,0.7); }
++.bcs-end { height: 34px; padding: 0 16px; border: 0; border-radius: 999px; background: #e11d48; color: #fff; font-size: 0.9rem; font-weight: 800; }
++.bcs-flags {
++ position: absolute; top: calc(56px + env(safe-area-inset-top, 0px)); left: 12px; right: 84px;
++ display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
++}
++.bcs-flag {
++ display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 999px;
++ background: rgba(225,29,72,0.92); font-size: 0.8rem; font-weight: 700;
++}
++.bcs-rail {
++ position: absolute; right: max(6px, env(safe-area-inset-right, 0px)); bottom: calc(14px + env(safe-area-inset-bottom, 0px));
++ display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: auto;
++}
++.bcs-btn { display: flex; flex-direction: column; align-items: center; gap: 3px; width: 64px; padding: 0; border: 0; background: none; }
++.bcs-btn[hidden] { display: none; }
++.bcs-ico {
++ display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; font-size: 1.12rem;
++ background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.18); transition: background 0.15s, transform 0.1s;
++}
++.bcs-btn:active .bcs-ico { transform: scale(0.94); }
++.bcs-btn.is-off .bcs-ico { background: #e11d48; border-color: transparent; }
++.bcs-btn[data-act="light"][aria-pressed="true"] .bcs-ico { background: #fde047; color: #111; border-color: transparent; }
++.bcs-btn.is-busy { opacity: 0.75; }
++.bcs-lbl { font-size: 0.7rem; font-weight: 700; line-height: 1.1; text-align: center; text-shadow: 0 1px 2px rgba(0,0,0,0.9); }
++
++.bcs-sheet-wrap {
++ position: fixed; inset: 0; z-index: 1300; display: flex; align-items: flex-end; justify-content: center;
++ background: rgba(0,0,0,0.5); animation: bcs-fade 0.15s ease-out;
++}
++.bcs-sheet-wrap.bcs-center { align-items: center; padding: 16px; }
++@keyframes bcs-fade { from { opacity: 0; } }
++.bcs-sheet {
++ width: 100%; max-width: 560px; max-height: 84vh; max-height: 84dvh; overflow-y: auto; overscroll-behavior: contain;
++ background: var(--bg-secondary, #15151c); color: var(--text-primary, #f4f4f5); border-radius: 22px 22px 0 0;
++ padding: 6px 16px calc(18px + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -10px 40px rgba(0,0,0,0.35);
++ animation: bcs-up 0.2s ease-out;
++}
++@keyframes bcs-up { from { transform: translateY(24px); opacity: 0.6; } }
++.bcs-grab { width: 40px; height: 4px; border-radius: 4px; background: var(--border, rgba(255,255,255,0.2)); margin: 4px auto 10px; }
++.bcs-sheet-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
++.bcs-sheet-who { flex: 1; min-width: 0; display: grid; }
++.bcs-sheet-who b { font-size: 1.02rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.bcs-sheet-who small, .bcs-row small, .bcs-tile small { color: var(--text-secondary, #a1a1aa); font-size: 0.76rem; }
++.bcs-x { width: 40px; height: 40px; flex: none; border-radius: 50%; border: 0; background: var(--surface-raised, rgba(255,255,255,0.08)); color: inherit; cursor: pointer; }
++.bcs-chip {
++ display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 14px; border-radius: 999px; cursor: pointer;
++ border: 1px solid var(--border, rgba(255,255,255,0.14)); background: var(--surface-raised, rgba(255,255,255,0.06));
++ color: inherit; font: inherit; font-weight: 600; font-size: 0.88rem;
++}
++.bcs-chip-strong { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); border-color: transparent; }
++.bcs-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
++.bcs-tile {
++ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 82px; padding: 10px 6px;
++ border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,0.08)); background: var(--surface-raised, rgba(255,255,255,0.05));
++ color: inherit; font: inherit; font-size: 0.8rem; font-weight: 600; line-height: 1.2; text-align: center; cursor: pointer;
++}
++.bcs-tile i { font-size: 1.15rem; color: var(--accent, #60a5fa); }
++.bcs-row {
++ display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; padding: 12px;
++ border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,0.08));
++}
++.bcs-row > div:first-child { display: grid; gap: 2px; min-width: 0; }
++.bcs-row-acts { display: flex; gap: 6px; flex: none; }
++.bcs-sec { margin-top: 14px; }
++.bcs-sec h3 { margin: 0 0 8px; font-size: 0.9rem; }
++.bcs-sec .bc-rs-control, .bcs-sec .bc-restream-control { display: flex !important; flex-direction: column; gap: 8px; margin: 0 0 8px; }
++.bcs-confirm {
++ width: 100%; max-width: 360px; padding: 20px; border-radius: 18px; text-align: center;
++ background: var(--bg-secondary, #15151c); color: var(--text-primary, #f4f4f5); box-shadow: 0 20px 60px rgba(0,0,0,0.5);
++}
++.bcs-confirm h3 { margin: 0 0 6px; font-size: 1.15rem; }
++.bcs-confirm p { margin: 0 0 16px; color: var(--text-secondary, #a1a1aa); font-size: 0.9rem; }
++.bcs-confirm-acts { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
++.bcs-confirm-acts .bcs-end { height: 40px; }
++.bcs-return {
++ position: fixed; left: 50%; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); z-index: 1100;
++ display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 999px;
++ background: #e11d48; color: #fff; font-weight: 700; box-shadow: 0 8px 24px rgba(0,0,0,0.35); cursor: pointer;
++}
++.bcs-return[hidden] { display: none; }
++@media (prefers-reduced-motion: reduce) {
++ .bcs-live-dot, .bcs-sheet, .bcs-sheet-wrap { animation: none; }
++}
ddiff --git a/public/features.json b/public/features.json
iindex d584486b..a7d1582a 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -213,7 +213,8 @@
"/js/broadcast-devices.js",
"/js/call.js",
"/js/broadcast-workspace.js",
-- "/js/broadcast.js"
++ "/js/broadcast.js",
++ "/js/broadcast-studio.js"
]
},
"pastes": {
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 67bbf828..1a5e0f96 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -663,7 +663,7 @@ WantedBy=multi-user.target</code>
<!-- Info bar -->
<div class="bc-info-bar" id="bc-info-bar" style="display:none">
<div class="bc-info-item">
-- <i class="fa-solid fa-eye"></i> <strong id="bc-viewer-count">0</strong> total viewers
++ <i class="fa-solid fa-eye"></i> <strong id="bc-viewer-count">0</strong> watching
</div>
<div class="bc-info-item" id="bc-rs-viewer-count" style="display:none">
<i class="fa-solid fa-robot"></i> <strong>0</strong> on RS
@@@ -683,38 +683,47 @@ WantedBy=multi-user.target</code>
<button class="bc-ctrl-btn bc-ctrl-btn-danger" id="bc-toggle-stream" onclick="stopBroadcast()">
<i class="fa-solid fa-stop"></i> Stop Stream
</button>
++ <button class="bc-ctrl-btn" id="bc-btn-mic-mute" onclick="bcStudio.toggleMic()" aria-pressed="false" style="display:none">
++ <i class="fa-solid fa-microphone"></i> Mute
++ </button>
++ <button class="bc-ctrl-btn" id="bc-btn-cam-off" onclick="bcStudio.toggleCamera()" aria-pressed="false" style="display:none">
++ <i class="fa-solid fa-video"></i> Camera off
++ </button>
++ <button class="bc-ctrl-btn" id="bc-btn-flip" onclick="bcStudio.flip()" title="Switch between the front and back camera" style="display:none">
++ <i class="fa-solid fa-camera-rotate"></i> Flip camera
++ </button>
<button class="bc-ctrl-btn" id="bc-btn-switch-cam" onclick="openCameraSwitcher()">
-- <i class="fa-solid fa-camera-rotate"></i> Switch Cam
++ <i class="fa-solid fa-camera"></i> Choose camera
</button>
<button class="bc-ctrl-btn" id="bc-btn-screenshare" onclick="toggleScreenShare()">
-- <i class="fa-solid fa-display"></i> Screen
++ <i class="fa-solid fa-display"></i> Share screen
</button>
<button class="bc-ctrl-btn" id="bc-btn-cam-overlay" onclick="toggleCameraOverlay()" style="display:none">
-- <i class="fa-solid fa-video"></i> Cam PiP
++ <i class="fa-solid fa-video"></i> Camera in corner
</button>
<button class="bc-ctrl-btn" id="bc-btn-toggle-mic" onclick="toggleScreenShareMic()" style="display:none">
<i class="fa-solid fa-microphone"></i> Mic
</button>
<button class="bc-ctrl-btn" onclick="toggleBroadcastStats()">
-- <i class="fa-solid fa-chart-line"></i> Stats
++ <i class="fa-solid fa-chart-line"></i> Stream health
</button>
-- <button class="bc-ctrl-btn" onclick="testTTS()">
-- <i class="fa-solid fa-volume-high"></i> Test TTS
++ <button class="bc-ctrl-btn" onclick="testTTS()" title="Hear how chat messages are read aloud">
++ <i class="fa-solid fa-volume-high"></i> Test chat voice
</button>
-- <button class="bc-ctrl-btn" onclick="cancelTTS()">
-- <i class="fa-solid fa-ban"></i> Reset TTS
++ <button class="bc-ctrl-btn" onclick="cancelTTS()" title="Stop the message being read aloud and clear the queue">
++ <i class="fa-solid fa-forward"></i> Skip spoken message
</button>
<button class="bc-ctrl-btn" id="bc-btn-clip" onclick="toggleBroadcastClipPanel()">
<i class="fa-solid fa-scissors"></i> Clip
</button>
<button class="bc-ctrl-btn" id="bc-btn-media-pip" onclick="toggleMediaPip()">
-- <i class="fa-solid fa-film"></i> Media
++ <i class="fa-solid fa-film"></i> Media requests
</button>
-- <button class="bc-ctrl-btn" id="bc-btn-toggle-preview" onclick="toggleBroadcastPreview()">
-- <i class="fa-solid fa-eye"></i> Preview
++ <button class="bc-ctrl-btn" id="bc-btn-toggle-preview" onclick="toggleBroadcastPreview()" title="Hide your own preview (you stay live; it saves battery)">
++ <i class="fa-solid fa-eye-slash"></i> Hide preview
</button>
-- <button class="bc-ctrl-btn" onclick="openViewerPreview()" title="Open a popup showing exactly what viewers see">
-- <i class="fa-solid fa-tv"></i> Viewer
++ <button class="bc-ctrl-btn" onclick="openViewerPreview()" title="Open your channel the way viewers see it">
++ <i class="fa-solid fa-tv"></i> Watch as viewer
</button>
</div>
@@@ -1186,7 +1195,7 @@ WantedBy=multi-user.target</code>
<div class="bc-camera-sheet-panel" role="dialog" aria-modal="true" aria-labelledby="bc-camera-sheet-title">
<div class="bc-camera-sheet-header">
<div>
-- <h3 id="bc-camera-sheet-title"><i class="fa-solid fa-camera"></i> Switch Camera</h3>
++ <h3 id="bc-camera-sheet-title"><i class="fa-solid fa-camera"></i> Choose a camera</h3>
<p id="bc-camera-sheet-subtitle">Pick the exact camera you want to use live.</p>
</div>
<button class="bc-camera-sheet-close" type="button" onclick="closeCameraSwitcher()" aria-label="Close camera switcher">
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
nnew file mode 100644
iindex 00000000..df44152e
---- /dev/null
++++ b/public/js/broadcast-studio.js
@@@ -0,0 +1,402 @@
++/* The live studio on a phone (owner, 2026-10-16: "I can't change cameras on the whim… all these buttons that don't
++ really mean anything"). While a stream from this browser is live on a narrow screen, the camera fills the screen
++ and the controls sit on the picture, the way phone streaming apps work:
++
++ top LIVE, time live, people watching, restreams, connection, End
++ right Flip (front ↔ back in one tap), Mic, Camera, Light (a back camera's torch), Chat, Clip, More
++ bottom the stream chat, over the picture
++ More a sheet: share, watch as a viewer, any camera, screen, media requests, a longer clip, chat read
++ aloud, restreams, stream health, all settings and the full page
++
++ It drives what broadcast.js already has (_switchActiveCamera, stopBroadcast, createBroadcastClip, …) and reads
++ the page's own counters, so there is no second copy of the streaming state. Mic and camera off set
++ MediaStreamTrack.enabled = false: viewers get silence or black while the stream and its recording go on. The
++ choice is applied again every second, so a replaced track (a camera switch, a recovery) keeps it.
++
++ window.bcStudio (toggleMic, toggleCamera, flip, open) also serves the desktop control row. */
++(function () {
++ 'use strict';
++ // A phone held upright, or turned sideways (wide but short, with a touch screen).
++ const NARROW = window.matchMedia('(max-width: 760px), (pointer: coarse) and (max-height: 520px)');
++ const st = {
++ root: null, micOff: false, camOff: false, torch: false, chatHidden: false, busy: false,
++ dismissedFor: null, hadLocal: false, sheet: null, confirm: null, parked: [], cameras: 0, lastFocus: null,
++ };
++
++ const $ = (id) => document.getElementById(id);
++ function el(tag, attrs, ...kids) {
++ const n = document.createElement(tag);
++ for (const [k, v] of Object.entries(attrs || {})) {
++ if (v == null || v === false) continue;
++ if (k === 'class') n.className = v;
++ else if (k === 'text') n.textContent = v;
++ else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v);
++ else n.setAttribute(k, v === true ? '' : v);
++ }
++ for (const kid of kids) if (kid != null && kid !== false) n.append(kid);
++ return n;
++ }
++ const icon = (name) => el('i', { class: `fa-solid ${name}`, 'aria-hidden': 'true' });
++ const say = (text, kind) => { if (typeof toast === 'function') toast(text, kind || 'info'); };
++
++ function streamState() {
++ try { return typeof getActiveStreamState === 'function' ? getActiveStreamState() : null; } catch { return null; }
++ }
++ function localStream() { const ss = streamState(); return ss && ss.localStream ? ss.localStream : null; }
++ function activeId() { try { return typeof broadcastState !== 'undefined' ? broadcastState.activeStreamId : null; } catch { return null; } }
++ function screenSharing() { try { return !!broadcastState.settings.screenShare; } catch { return false; } }
++ function videoTrack() { const s = localStream(); return s ? s.getVideoTracks()[0] || null : null; }
++ function onBroadcastPage() { const p = $('page-broadcast'); return !!(p && p.classList.contains('active')); }
++ function browserViewShown() { const v = $('bc-browser-broadcast'); return !!(v && v.style.display !== 'none'); }
++ function liveHere() { return onBroadcastPage() && browserViewShown() && !!localStream(); }
++ function wanted() { return NARROW.matches && liveHere() && st.dismissedFor !== activeId(); }
++
++ // ── Mic, camera and light: the choice holds across replaced tracks ──
++ function applyTracks() {
++ const s = localStream();
++ if (!s) return;
++ for (const t of s.getAudioTracks()) if (t.enabled === st.micOff) t.enabled = !st.micOff;
++ for (const t of s.getVideoTracks()) if (t.enabled === st.camOff) t.enabled = !st.camOff;
++ }
++ function torchCapable() {
++ const t = videoTrack();
++ try { return !!(t && typeof t.getCapabilities === 'function' && t.getCapabilities().torch); } catch { return false; }
++ }
++ function facing() {
++ const t = videoTrack();
++ if (!t) return null;
++ const s = (t.getSettings && t.getSettings()) || {};
++ if (s.facingMode) return s.facingMode;
++ return typeof _getCameraFacingHint === 'function' ? _getCameraFacingHint({ label: t.label }) : null;
++ }
++
++ function toggleMic() { st.micOff = !st.micOff; applyTracks(); refresh(); }
++ function toggleCamera() { st.camOff = !st.camOff; applyTracks(); refresh(); }
++ async function toggleLight() {
++ const t = videoTrack();
++ if (!t || !torchCapable()) return;
++ const on = !st.torch;
++ try { await t.applyConstraints({ advanced: [{ torch: on }] }); st.torch = on; } catch { say('This camera cannot turn its light on', 'error'); }
++ refresh();
++ }
++
++ /** Front ↔ back in one tap: the first camera facing the other way (a phone's main one), else the next camera. */
++ async function flip() {
++ if (st.busy) return;
++ if (screenSharing()) return say('You are sharing your screen. Stop sharing to use the camera.');
++ if (typeof _switchActiveCamera !== 'function' || typeof _enumerateBroadcastVideoInputs !== 'function') return;
++ st.busy = true;
++ refresh();
++ try {
++ const cams = await _enumerateBroadcastVideoInputs({ ensureLabels: false });
++ st.cameras = cams.length;
++ if (cams.length < 2) { say('This device has only one camera'); return; }
++ const now = (videoTrack() && videoTrack().getSettings && videoTrack().getSettings()) || {};
++ const was = facing();
++ const other = was && cams.find((d) => d.deviceId !== now.deviceId && _getCameraFacingHint(d) && _getCameraFacingHint(d) !== was);
++ const i = cams.findIndex((d) => d.deviceId === now.deviceId);
++ const next = other || cams[(i + 1) % cams.length];
++ st.torch = false;
++ await _switchActiveCamera(next.deviceId);
++ applyTracks();
++ } catch (err) {
++ say('Could not switch camera: ' + ((err && err.message) || 'unknown error'), 'error');
++ } finally {
++ st.busy = false;
++ refresh();
++ }
++ }
++
++ function quickClip(seconds) {
++ if (typeof createBroadcastClip !== 'function' || typeof _getBroadcastUptimeSeconds !== 'function') return;
++ const end = Math.floor(_getBroadcastUptimeSeconds());
++ if (end < 5) return say('Stream a few more seconds first, then clip it');
++ // The clip panel's marks are what createBroadcastClip reads.
++ if (seconds === 30 && typeof setBroadcastClipLast30 === 'function') setBroadcastClipLast30();
++ else { try { _bcClipEnd = end; _bcClipStart = Math.max(0, end - seconds); } catch { /* the panel's own defaults stand */ } }
++ const title = $('bc-clip-title');
++ if (title) title.value = '';
++ say(`Clipping the last ${Math.min(seconds, end)} seconds…`);
++ createBroadcastClip();
++ }
++
++ async function share() {
++ const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : null;
++ if (!user) return;
++ const url = `${location.origin}/@${encodeURIComponent(user)}`;
++ const ss = streamState();
++ const title = (ss && ss.streamData && ss.streamData.title) || 'My stream';
++ if (navigator.share) {
++ try { await navigator.share({ title, url }); return; } catch (err) { if (err && err.name === 'AbortError') return; }
++ }
++ if (typeof copyToClipboard === 'function') copyToClipboard(url);
++ else if (navigator.clipboard) navigator.clipboard.writeText(url).then(() => say('Link copied', 'success'), () => {});
++ }
++
++ // ── The overlay ──
++ function railButton(act, ico, label, onClick) {
++ return el('button', { type: 'button', class: 'bcs-btn', 'data-act': act, onclick: onClick },
++ el('span', { class: 'bcs-ico' }, icon(ico)), el('span', { class: 'bcs-lbl', text: label }));
++ }
++ function build() {
++ const top = el('div', { class: 'bcs-top' },
++ el('div', { class: 'bcs-top-l' },
++ el('span', { class: 'bcs-live' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }), 'LIVE'),
++ el('span', { class: 'bcs-pill', id: 'bcs-time', title: 'Time live' }, '0:00'),
++ el('span', { class: 'bcs-pill', id: 'bcs-viewers', title: 'Watching now' }, icon('fa-eye'), el('b', { text: '0' })),
++ el('button', { type: 'button', class: 'bcs-pill bcs-restream', id: 'bcs-restream', hidden: true, onclick: () => openSheet('restreams') })),
++ el('div', { class: 'bcs-top-r' },
++ el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', onclick: toggleHealth },
++ el('span', { class: 'bcs-dot', 'aria-hidden': 'true' }), el('span', { class: 'bcs-health-t', text: 'Connecting' })),
++ el('button', { type: 'button', class: 'bcs-end', id: 'bcs-end', onclick: openConfirm }, 'End')));
++ const rail = el('div', { class: 'bcs-rail', role: 'toolbar', 'aria-label': 'Live controls', 'aria-orientation': 'vertical' },
++ railButton('flip', 'fa-camera-rotate', 'Flip', flip),
++ railButton('mic', 'fa-microphone', 'Mic', toggleMic),
++ railButton('cam', 'fa-video', 'Camera', toggleCamera),
++ railButton('light', 'fa-bolt', 'Light', toggleLight),
++ railButton('chat', 'fa-comment', 'Chat', () => { st.chatHidden = !st.chatHidden; refresh(); }),
++ railButton('clip', 'fa-scissors', 'Clip', () => quickClip(30)),
++ railButton('more', 'fa-ellipsis', 'More', () => openSheet()));
++ return el('div', { class: 'bcs', id: 'bc-studio' }, top, el('div', { class: 'bcs-flags', id: 'bcs-flags', 'aria-live': 'polite' }), rail);
++ }
++
++ function mount() {
++ document.body.classList.add('bc-studio-on');
++ st.root = build();
++ document.body.append(st.root);
++ // The overlay chat replaces the phone chat sheet.
++ try { if (typeof _bcMobileChatOpen !== 'undefined' && _bcMobileChatOpen && typeof toggleBroadcastMobileChat === 'function') toggleBroadcastMobileChat(); } catch { /* not open */ }
++ const v = $('bc-video-preview');
++ if (v) v.play().catch(() => {});
++ const msgs = $('bc-chat-messages');
++ if (msgs) msgs.scrollTop = msgs.scrollHeight;
++ if (typeof _enumerateBroadcastVideoInputs === 'function') _enumerateBroadcastVideoInputs({ ensureLabels: false }).then((c) => { st.cameras = c.length; refresh(); }, () => {});
++ refresh();
++ }
++ function unmount() {
++ closeSheet();
++ closeConfirm();
++ document.body.classList.remove('bc-studio-on', 'bc-studio-chat-off');
++ if (st.root) st.root.remove();
++ st.root = null;
++ const v = $('bc-video-preview');
++ if (v) { v.classList.remove('bcs-mirror'); v.play().catch(() => {}); }
++ }
++
++ function text(id) { const n = $(id); return n ? n.textContent.trim() : ''; }
++ function restreamSummary() {
++ const scope = [$('bc-rs-control'), $('bc-restream-control')].filter((n) => n && n.style.display !== 'none');
++ let live = 0, starting = 0, failed = 0;
++ for (const n of scope) {
++ live += n.querySelectorAll('.bc-rs-live, .bc-restream-status-live').length;
++ starting += n.querySelectorAll('.bc-rs-connecting, .bc-restream-status-starting').length;
++ failed += n.querySelectorAll('.bc-rs-error, .bc-restream-status-error, .bc-restream-status-failed').length;
++ }
++ if (failed) return { cls: 'is-bad', text: failed === 1 ? 'Restream failed' : `${failed} restreams failed` };
++ if (starting) return { cls: 'is-warn', text: 'Restream connecting' };
++ if (live) return { cls: 'is-ok', text: `Also on ${live}` };
++ return null;
++ }
++ const HEALTH = {
++ good: ['is-ok', 'Good'], fair: ['is-warn', 'Weak'], poor: ['is-bad', 'Poor'], offline: ['is-bad', 'Offline'],
++ connecting: ['is-warn', 'Connecting'], measuring: ['is-warn', 'Checking'],
++ };
++
++ /** Brings the overlay up to date with the page (once a second, and after every action). */
++ function refresh() {
++ applyTracks();
++ syncDesktopButtons();
++ if (!st.root) return;
++ document.body.classList.toggle('bc-studio-chat-off', st.chatHidden);
++ // 0:05:07 → 5:07 while under an hour.
++ const up = /^(\d+):(\d\d):(\d\d)$/.exec(text('bc-uptime'));
++ $('bcs-time').textContent = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');
++ const viewers = $('bcs-viewers').querySelector('b');
++ viewers.textContent = text('bc-viewer-count') || '0';
++ $('bcs-viewers').title = `${viewers.textContent} watching now`;
++
++ const rs = restreamSummary();
++ const chip = $('bcs-restream');
++ chip.hidden = !rs;
++ if (rs) { chip.className = `bcs-pill bcs-restream ${rs.cls}`; chip.textContent = rs.text; }
++
++ const conn = $('bc-connection-status');
++ const bad = conn && conn.classList.contains('bc-status-bad');
++ const [cls, label] = bad ? ['is-bad', 'Reconnecting'] : (HEALTH[text('bc-stat-health').toLowerCase()] || ['is-warn', 'Connecting']);
++ const health = $('bcs-health');
++ health.className = `bcs-pill bcs-health ${cls}`;
++ health.querySelector('.bcs-health-t').textContent = label;
++ health.setAttribute('aria-label', `Connection: ${label}. Show stream health`);
++
++ const set = (act, { on, ico, label: lbl, hidden, busy }) => {
++ const b = st.root.querySelector(`[data-act="${act}"]`);
++ if (!b) return;
++ b.hidden = !!hidden;
++ if (on !== undefined) b.setAttribute('aria-pressed', on ? 'true' : 'false');
++ if (ico) b.querySelector('i').className = `fa-solid ${ico}`;
++ if (lbl) b.querySelector('.bcs-lbl').textContent = lbl;
++ b.classList.toggle('is-off', act === 'mic' ? st.micOff : act === 'cam' ? st.camOff : false);
++ b.classList.toggle('is-busy', !!busy);
++ };
++ set('flip', { hidden: st.cameras === 1 || screenSharing(), busy: st.busy, ico: st.busy ? 'fa-spinner fa-spin' : 'fa-camera-rotate' });
++ set('mic', { on: st.micOff, ico: st.micOff ? 'fa-microphone-slash' : 'fa-microphone', label: st.micOff ? 'Unmute' : 'Mute' });
++ set('cam', { on: st.camOff, ico: st.camOff ? 'fa-video-slash' : 'fa-video', label: st.camOff ? 'Camera on' : 'Camera' });
++ set('light', { hidden: !torchCapable(), on: st.torch, label: st.torch ? 'Light off' : 'Light' });
++ set('chat', { on: !st.chatHidden, ico: st.chatHidden ? 'fa-comment-slash' : 'fa-comment', label: st.chatHidden ? 'Show chat' : 'Chat' });
++
++ const flags = [];
++ if (st.micOff) flags.push(['fa-microphone-slash', 'Mic off: viewers hear nothing']);
++ if (st.camOff) flags.push(['fa-video-slash', 'Camera off: viewers see black']);
++ const v = $('bc-video-preview');
++ if (v && v.style.display === 'none') flags.push(['fa-eye-slash', 'Preview hidden, still live']);
++ const box = $('bcs-flags');
++ const sig = JSON.stringify(flags);
++ if (box.dataset.sig !== sig) {
++ box.dataset.sig = sig;
++ box.replaceChildren(...flags.map(([i, t]) => el('span', { class: 'bcs-flag' }, icon(i), t)));
++ }
++ if (v) v.classList.toggle('bcs-mirror', facing() === 'user' && !screenSharing());
++ }
++
++ // ── Desktop row: the same Mute / Camera off / Flip ──
++ function syncDesktopButtons() {
++ const live = !!localStream();
++ const mic = $('bc-btn-mic-mute');
++ if (mic) {
++ mic.style.display = live && !screenSharing() ? '' : 'none';
++ mic.classList.toggle('bc-ctrl-btn-active', st.micOff);
++ mic.setAttribute('aria-pressed', st.micOff ? 'true' : 'false');
++ mic.replaceChildren(icon(st.micOff ? 'fa-microphone-slash' : 'fa-microphone'), st.micOff ? ' Unmute' : ' Mute');
++ }
++ const cam = $('bc-btn-cam-off');
++ if (cam) {
++ cam.style.display = live && !screenSharing() ? '' : 'none';
++ cam.classList.toggle('bc-ctrl-btn-active', st.camOff);
++ cam.setAttribute('aria-pressed', st.camOff ? 'true' : 'false');
++ cam.replaceChildren(icon(st.camOff ? 'fa-video-slash' : 'fa-video'), st.camOff ? ' Camera on' : ' Camera off');
++ }
++ const fl = $('bc-btn-flip');
++ if (fl) fl.style.display = live && !screenSharing() && st.cameras > 1 ? '' : 'none';
++ const back = $('bcs-return');
++ if (back) back.hidden = !(NARROW.matches && liveHere() && st.dismissedFor === activeId());
++ }
++
++ // ── More ──
++ function tile(ico, label, onClick, hint) {
++ return el('button', { type: 'button', class: 'bcs-tile', onclick: onClick },
++ icon(ico), el('span', { class: 'bcs-tile-t', text: label }), hint ? el('small', { text: hint }) : null);
++ }
++ /** The full page, optionally scrolled to `target` (a selector); a button brings the studio back. */
++ function leave(target) {
++ st.dismissedFor = activeId();
++ unmount();
++ ensureReturn();
++ syncDesktopButtons();
++ if (target) { const n = document.querySelector(target); if (n) n.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
++ }
++ function ensureReturn() {
++ if ($('bcs-return')) return;
++ document.body.append(el('button', { type: 'button', class: 'bcs-return', id: 'bcs-return', hidden: true,
++ onclick: () => { st.dismissedFor = null; tick(); } }, icon('fa-mobile-screen'), ' Back to live studio'));
++ }
++
++ function openSheet(section) {
++ closeSheet();
++ st.lastFocus = document.activeElement;
++ const ss = streamState();
++ const title = (ss && ss.streamData && ss.streamData.title) || 'Your stream';
++ const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : '';
++ const preview = $('bc-video-preview');
++ const previewHidden = preview && preview.style.display === 'none';
++ const canScreen = !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) && typeof toggleScreenShare === 'function';
++ const after = (fn) => () => { closeSheet(); fn(); };
++
++ const grid = el('div', { class: 'bcs-grid' },
++ tile('fa-tv', 'Watch as a viewer', after(() => openViewerPreview())),
++ tile('fa-camera', 'Choose camera', after(() => openCameraSwitcher())),
++ canScreen ? tile('fa-display', screenSharing() ? 'Stop sharing screen' : 'Share screen', after(() => toggleScreenShare())) : null,
++ tile('fa-film', 'Media requests', after(() => toggleMediaPip())),
++ tile('fa-scissors', 'Clip last minute', after(() => quickClip(60))),
++ tile(previewHidden ? 'fa-eye' : 'fa-eye-slash', previewHidden ? 'Show my preview' : 'Hide my preview', after(() => toggleBroadcastPreview()), previewHidden ? null : 'Saves battery'),
++ tile('fa-chart-line', 'Stream health', after(toggleHealth)),
++ tile('fa-sliders', 'All settings', after(() => leave('#bc-live-section .bc-settings-group'))),
++ tile('fa-up-right-and-down-left-from-center', 'Full page', after(() => leave())));
++
++ const voice = el('div', { class: 'bcs-row' },
++ el('div', null, el('b', { text: 'Chat read aloud' }), el('small', { text: 'Messages are spoken on this device' })),
++ el('div', { class: 'bcs-row-acts' },
++ el('button', { type: 'button', class: 'bcs-chip', onclick: () => testTTS() }, 'Test'),
++ el('button', { type: 'button', class: 'bcs-chip', onclick: () => cancelTTS() }, 'Skip current')));
++
++ const restreams = el('div', { class: 'bcs-sec', id: 'bcs-restreams' }, el('h3', { text: 'Restreams' }));
++ for (const id of ['bc-rs-control', 'bc-restream-control']) {
++ const n = $(id);
++ if (!n || n.style.display === 'none' || !n.childElementCount) continue;
++ const mark = document.createComment(`bc-studio:${id}`);
++ n.before(mark);
++ st.parked.push({ node: n, mark });
++ restreams.append(n);
++ }
++
++ const sheet = el('div', { class: 'bcs-sheet', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'bcs-sheet-title' },
++ el('div', { class: 'bcs-grab', 'aria-hidden': 'true' }),
++ el('div', { class: 'bcs-sheet-head' },
++ el('div', { class: 'bcs-sheet-who' }, el('b', { id: 'bcs-sheet-title', text: title }), user ? el('small', { text: `openvibe.live/@${user}` }) : null),
++ el('button', { type: 'button', class: 'bcs-chip bcs-chip-strong', onclick: share }, icon('fa-share-nodes'), ' Share'),
++ el('button', { type: 'button', class: 'bcs-x', 'aria-label': 'Close', onclick: closeSheet }, icon('fa-xmark'))),
++ grid, voice, st.parked.length ? restreams : null);
++ st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);
++ document.body.append(st.sheet);
++ const focus = section === 'restreams' && st.parked.length ? restreams : sheet.querySelector('.bcs-tile');
++ if (section === 'restreams' && st.parked.length) restreams.scrollIntoView({ block: 'start' });
++ if (focus && focus.focus) focus.focus({ preventScroll: section === 'restreams' });
++ }
++ function closeSheet() {
++ for (const { node, mark } of st.parked.splice(0)) { mark.replaceWith(node); }
++ if (st.sheet) { st.sheet.remove(); st.sheet = null; if (st.lastFocus && st.lastFocus.focus) st.lastFocus.focus(); }
++ }
++
++ function toggleHealth() { if (typeof toggleBroadcastStats === 'function') toggleBroadcastStats(); }
++
++ function openConfirm() {
++ closeConfirm();
++ st.lastFocus = document.activeElement;
++ const keep = el('button', { type: 'button', class: 'bcs-chip', onclick: closeConfirm }, 'Keep streaming');
++ const box = el('div', { class: 'bcs-confirm', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'bcs-confirm-t', 'aria-describedby': 'bcs-confirm-d' },
++ el('h3', { id: 'bcs-confirm-t', text: 'End your stream?' }),
++ el('p', { id: 'bcs-confirm-d', text: 'Viewers will see it end. Restreams stop too, and the recording goes to your VODs.' }),
++ el('div', { class: 'bcs-confirm-acts' }, keep,
++ el('button', { type: 'button', class: 'bcs-end', onclick: () => { closeConfirm(); stopBroadcast(); } }, 'End stream')));
++ st.confirm = el('div', { class: 'bcs-sheet-wrap bcs-center', onclick: (e) => { if (e.target === st.confirm) closeConfirm(); } }, box);
++ document.body.append(st.confirm);
++ keep.focus();
++ }
++ function closeConfirm() {
++ if (st.confirm) { st.confirm.remove(); st.confirm = null; if (st.lastFocus && st.lastFocus.focus) st.lastFocus.focus(); }
++ }
++
++ document.addEventListener('keydown', (e) => {
++ if (e.key !== 'Escape') return;
++ if (st.confirm) closeConfirm();
++ else if (st.sheet) closeSheet();
++ });
++
++ // ── Once a second: mount, update or leave ──
++ function tick() {
++ const has = !!localStream();
++ if (has !== st.hadLocal) {
++ st.hadLocal = has;
++ // The camera button was hidden when the live view showed before the camera started.
++ if (typeof syncBroadcastLiveButtonVisibility === 'function') syncBroadcastLiveButtonVisibility();
++ if (has && typeof _enumerateBroadcastVideoInputs === 'function') _enumerateBroadcastVideoInputs({ ensureLabels: false }).then((c) => { st.cameras = c.length; syncDesktopButtons(); }, () => {});
++ if (!has) { st.micOff = false; st.camOff = false; st.torch = false; st.dismissedFor = null; }
++ }
++ if (wanted()) { if (!st.root) mount(); refresh(); }
++ else { if (st.root) unmount(); applyTracks(); syncDesktopButtons(); }
++ }
++ setInterval(tick, 1000);
++ if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
++ ensureReturn();
++
++ window.bcStudio = { toggleMic, toggleCamera, flip, open: () => { st.dismissedFor = null; tick(); }, _state: st };
++})();
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex bc6d9661..89898dde 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -269,13 +269,13 @@ function updateRsRestreamSlotUI() {
// Status + action buttons
html += '<div class="bc-rs-status-row">';
if (isLive) {
-- html += `<span class="bc-rs-status bc-rs-live"><i class="fa-solid fa-circle"></i> RS Live</span>`;
++ html += `<span class="bc-rs-status bc-rs-live"><i class="fa-solid fa-circle"></i> Live on RobotStreamer</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Stop</button>`;
} else if (isStarting) {
html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Connecting…</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else if (hasFailed) {
-- html += `<span class="bc-rs-status bc-rs-error"><i class="fa-solid fa-circle-exclamation"></i> Failed</span>`;
++ html += `<span class="bc-rs-status bc-rs-error"><i class="fa-solid fa-circle-exclamation"></i> RobotStreamer failed</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="startOrRetryRsRestream()"><i class="fa-solid fa-rotate-right"></i> Retry</button>`;
} else if (slotId && slotSs?.localStream) {
// Slot assigned but not started yet (could be reconnecting)
@@@ -284,12 +284,12 @@ function updateRsRestreamSlotUI() {
html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Reconnecting…</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else {
-- html += `<span class="bc-rs-status bc-rs-ready"><i class="fa-solid fa-robot"></i> Ready</span>`;
++ html += `<span class="bc-rs-status bc-rs-ready"><i class="fa-solid fa-robot"></i> RobotStreamer ready</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="startOrRetryRsRestream()"><i class="fa-solid fa-play"></i> Start</button>`;
}
} else {
// No slot assigned
-- html += `<span class="bc-rs-status bc-rs-off"><i class="fa-solid fa-robot"></i> RS Off</span>`;
++ html += `<span class="bc-rs-status bc-rs-off"><i class="fa-solid fa-robot"></i> RobotStreamer off</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="startOrRetryRsRestream()"><i class="fa-solid fa-play"></i> Start</button>`;
}
html += '</div>';
@@@ -1557,6 +1557,8 @@ async function createNewStream() {
} else {
await startMediaCapture(streamData.id, { cameraId: createCamera, audioId: createAudio });
}
++ // The live view showed before the camera started, which hid the camera buttons.
++ syncBroadcastLiveButtonVisibility();
connectSignaling(streamData.id);
maybeStartRsRestream(streamData.id);
@@@ -3102,15 +3104,14 @@ function rsDetailLine(rs) {
const bits = [];
const now = Date.now();
if (rs.state === 'live') {
-- bits.push(`up ${fmtUptime(rs.uptime_ms || 0)}`);
-- bits.push(rs.video_codec ? `${String(rs.video_codec).replace('video/', '')} passthrough` : 'passthrough');
-- if (!rs.has_audio) bits.push('<span class="bc-restream-warn">no audio</span>');
++ bits.push(`live for ${fmtUptime(rs.uptime_ms || 0)}`);
++ if (!rs.has_audio) bits.push('<span class="bc-restream-warn">no sound reaching RobotStreamer</span>');
if (rs.rs) {
-- if (rs.rs.rtt_ms != null) bits.push(`${rs.rs.rtt_ms} ms to RS`);
++ if (rs.rs.rtt_ms != null) bits.push(`${rs.rs.rtt_ms} ms delay`);
if (rs.rs.loss_pct != null && rs.rs.loss_pct >= 1) bits.push(`<span class="bc-restream-warn">${rs.rs.loss_pct}% loss</span>`);
if (rs.rs.stalled_ms > 1500) bits.push(`<span class="bc-restream-warn">source stalled ${Math.round(rs.rs.stalled_ms / 1000)}s</span>`);
}
-- if (rs.restarts) bits.push(`${rs.restarts} reconnect${rs.restarts === 1 ? '' : 's'}`);
++ if (rs.restarts) bits.push(rs.restarts === 1 ? 'reconnected once' : `reconnected ${rs.restarts} times`);
} else if (rs.state === 'starting') {
bits.push('connecting to RobotStreamer…');
} else if (rs.state === 'restarting') {
@@@ -4261,13 +4262,12 @@ let _platformViewerBreakdown = [];
function updateBroadcastStatus(state) {
const el = document.getElementById('bc-connection-status'); if (!el) return;
// Determine streaming method label from active stream
-- const ss = getActiveStreamState();
-- const proto = (ss?.streamData?.protocol || 'webrtc').toUpperCase();
++ // Connected says nothing (it used to print the protocol, "WEBRTC"); only trouble shows.
const map = {
-- connected: { text: proto, cls: 'bc-status-good' },
++ connected: { text: '', cls: 'bc-status-good is-quiet' },
checking: { text: 'Connecting…', cls: 'bc-status-warn' },
-- completed: { text: proto, cls: 'bc-status-good' },
-- disconnected: { text: 'Disconnected', cls: 'bc-status-bad' },
++ completed: { text: '', cls: 'bc-status-good is-quiet' },
++ disconnected: { text: 'Reconnecting…', cls: 'bc-status-bad' },
failed: { text: 'Failed', cls: 'bc-status-bad' },
error: { text: 'Error', cls: 'bc-status-bad' },
new: { text: 'Starting…', cls: 'bc-status-warn' },
@@@ -5073,27 +5073,6 @@ function syncBroadcastLiveButtonVisibility() {
// Mic / Cam PiP are further gated by _syncScreenShareLiveControls()
}
--/**
-- * Sync which live-control buttons are visible/enabled based on the current active
-- * stream's protocol. Browser streams show Screen/Cam-PiP/Mic; RTMP/JSMPEG/WHIP do not.
-- */
--function syncBroadcastLiveButtonVisibility() {
-- const isBrowser = !!(getActiveStreamState()?.localStream);
-- const hasDisplayMedia = !!(navigator.mediaDevices?.getDisplayMedia);
--
-- const screenBtn = document.getElementById('bc-btn-screenshare');
-- const switchCamBtn = document.getElementById('bc-btn-switch-cam');
--
-- if (screenBtn) {
-- screenBtn.style.display = (isBrowser && hasDisplayMedia) ? '' : 'none';
-- screenBtn.title = hasDisplayMedia ? 'Toggle screen share' : 'Screen sharing not supported in this browser';
-- }
-- if (switchCamBtn) {
-- switchCamBtn.style.display = isBrowser ? '' : 'none';
-- }
-- // Mic / Cam PiP are further gated by _syncScreenShareLiveControls()
--}
--
function toggleBroadcastStats() { const el = document.getElementById('bc-stats-overlay'); if (el) el.style.display = el.style.display === 'none' ? 'flex' : 'none'; }
/* ── Group Call Controls ───────────────────────────────────────── */
@@@ -6184,10 +6163,10 @@ function toggleBroadcastPreview() {
if (!video || !btn) return;
if (video.style.display === 'none') {
video.style.display = '';
-- btn.innerHTML = '<i class="fa-solid fa-eye"></i> Preview';
++ btn.innerHTML = '<i class="fa-solid fa-eye-slash"></i> Hide preview';
} else {
video.style.display = 'none';
-- btn.innerHTML = '<i class="fa-solid fa-eye-slash"></i> Preview';
++ btn.innerHTML = '<i class="fa-solid fa-eye"></i> Show preview';
}
}
ddiff --git a/test/broadcast-studio.test.js b/test/broadcast-studio.test.js
nnew file mode 100644
iindex 00000000..dac0aa13
---- /dev/null
++++ b/test/broadcast-studio.test.js
@@@ -0,0 +1,175 @@
++/**
++ * The live studio on a phone (public/js/broadcast-studio.js; owner, 2026-10-16: "I can't change cameras on the
++ * whim… all these buttons that don't really mean anything").
++ *
++ * - Flip goes front ↔ back in one tap: to the first camera facing the other way (a phone lists its main camera
++ * first, then ultra-wide and others), else to the next camera; one camera says so; screen sharing says to stop it.
++ * - Mute and camera off are track.enabled = false, and they hold when the track is replaced (a camera switch, a
++ * recovery) because the studio applies them again every second.
++ * - The camera buttons are visible once the camera runs: the live view used to show before capture started, which
++ * hid "Switch Cam" for the whole stream.
++ * - The live controls say what they do: "watching" (not "total viewers"), no "WEBRTC" badge, RobotStreamer's line
++ * in words (no "VP8 passthrough · 75 ms to RS"), "Test chat voice" / "Skip spoken message" (not "Test TTS" /
++ * "Reset TTS"), and the studio script loads with the broadcast feature.
++ *
++ * A real browser pass (Chrome's fake camera, phone, sideways phone and desktop) is in the PR; this is what `npm test`
++ * can check without one.
++ *
++ * node test/broadcast-studio.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
++const studioSrc = read('public/js/broadcast-studio.js');
++const devicesSrc = read('public/js/broadcast-devices.js');
++const bc = read('public/js/broadcast.js');
++const fragment = read('public/fragments/broadcast.html');
++const featuresFile = JSON.parse(read('public/features.json'));
++const features = featuresFile.features || featuresFile;
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err && err.stack ? err.stack.split('\n').slice(0, 3).join('\n ') : err}`); }
++}
++
++/** The source of a top-level function declaration, braces matched. */
++function extract(src, name) {
++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
++ assert.ok(m, `${name}() must exist`);
++ let i = src.indexOf('{', m.index);
++ let depth = 0;
++ for (; i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
++ }
++ throw new Error(`unbalanced ${name}`);
++}
++
++function fakeElement(tag) {
++ const kids = [];
++ const attrs = {};
++ return {
++ tagName: String(tag).toUpperCase(), style: {}, dataset: {}, hidden: false, children: kids, className: '', textContent: '',
++ classList: { _s: new Set(), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle(c, on) { if (on === undefined ? !this._s.has(c) : on) this._s.add(c); else this._s.delete(c); }, contains(c) { return this._s.has(c); } },
++ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return attrs[k]; },
++ addEventListener() {}, append(...n) { kids.push(...n); }, replaceChildren(...n) { kids.splice(0, kids.length, ...n); },
++ querySelector() { return null; }, querySelectorAll() { return []; }, remove() {}, before() {}, focus() {},
++ };
++}
++function track(kind, settings = {}, label = '') {
++ return { kind, enabled: true, label, getSettings: () => settings, getCapabilities: () => ({}), applyConstraints: async () => {} };
++}
++
++/** Load the studio with a stream whose tracks are `stream`; returns its API and what it called. */
++function loadStudio({ stream, cameras = [], screenShare = false }) {
++ const calls = { switched: [], toasts: [] };
++ const ticks = [];
++ const els = {};
++ const g = {
++ window: { matchMedia: () => ({ matches: true, addEventListener() {} }) },
++ document: {
++ getElementById: (id) => els[id] || null,
++ createElement: fakeElement, createComment: () => fakeElement('#comment'),
++ querySelector: () => null, addEventListener() {}, activeElement: null,
++ body: { classList: fakeElement('body').classList, append() {} },
++ },
++ setInterval: (fn) => { ticks.push(fn); return 1; },
++ navigator: {}, location: { origin: 'https://openvibe.live' },
++ broadcastState: { activeStreamId: 7, settings: { screenShare } },
++ getActiveStreamState: () => ({ localStream: stream, streamData: { title: 'Test' } }),
++ toast: (msg, kind) => calls.toasts.push([kind || 'info', msg]),
++ _enumerateBroadcastVideoInputs: async () => cameras,
++ _switchActiveCamera: async (id) => { calls.switched.push(id); },
++ syncBroadcastLiveButtonVisibility: () => { calls.synced = (calls.synced || 0) + 1; },
++ };
++ // eslint-disable-next-line no-new-func
++ const getHint = new Function(`${extract(devicesSrc, '_getCameraFacingHint')}; return _getCameraFacingHint;`)();
++ g._getCameraFacingHint = getHint;
++ // eslint-disable-next-line no-new-func
++ new Function(...Object.keys(g), studioSrc)(...Object.values(g));
++ return { api: g.window.bcStudio, calls, tick: () => ticks.forEach((f) => f()), els, g };
++}
++
++(async () => {
++ await check('Mute and camera off disable the tracks, and hold when a track is replaced', async () => {
++ const audio = track('audio');
++ const video = track('video', { deviceId: 'front', facingMode: 'user' });
++ const tracks = { a: [audio], v: [video] };
++ const stream = { getAudioTracks: () => tracks.a, getVideoTracks: () => tracks.v, getTracks: () => [...tracks.a, ...tracks.v] };
++ const s = loadStudio({ stream });
++ s.api.toggleMic();
++ s.api.toggleCamera();
++ assert.deepStrictEqual([audio.enabled, video.enabled], [false, false]);
++ // A camera switch or a recovery hands the stream a new, enabled track: the next tick turns it off again.
++ const fresh = track('video', { deviceId: 'back' });
++ tracks.v = [fresh];
++ s.tick();
++ assert.strictEqual(fresh.enabled, false, 'the replaced camera track stays off');
++ s.api.toggleMic();
++ s.api.toggleCamera();
++ assert.deepStrictEqual([audio.enabled, fresh.enabled], [true, true]);
++ });
++
++ await check('Flip goes to the main camera facing the other way, then back', async () => {
++ const cams = [
++ { deviceId: 'b0', label: 'camera2 0, facing back' },
++ { deviceId: 'f1', label: 'camera2 1, facing front' },
++ { deviceId: 'b2', label: 'camera2 2, facing back' },
++ ];
++ let current = track('video', { deviceId: 'f1', facingMode: 'user' });
++ const stream = { getAudioTracks: () => [], getVideoTracks: () => [current], getTracks: () => [current] };
++ const s = loadStudio({ stream, cameras: cams });
++ await s.api.flip();
++ assert.deepStrictEqual(s.calls.switched, ['b0'], 'front → the first back camera (the main one, not the ultra-wide)');
++ current = track('video', { deviceId: 'b0', facingMode: 'environment' });
++ await s.api.flip();
++ assert.deepStrictEqual(s.calls.switched, ['b0', 'f1'], 'back → front');
++ });
++
++ await check('Flip without facing labels goes to the next camera; one camera or a shared screen says so', async () => {
++ const cams = [{ deviceId: 'x', label: 'USB Camera' }, { deviceId: 'y', label: 'HD Webcam' }];
++ const cur = track('video', { deviceId: 'x' }, 'USB Camera');
++ const stream = { getAudioTracks: () => [], getVideoTracks: () => [cur], getTracks: () => [cur] };
++ const s = loadStudio({ stream, cameras: cams });
++ await s.api.flip();
++ assert.deepStrictEqual(s.calls.switched, ['y']);
++ const one = loadStudio({ stream, cameras: [cams[0]] });
++ await one.api.flip();
++ assert.deepStrictEqual([one.calls.switched, one.calls.toasts[0][1]], [[], 'This device has only one camera']);
++ const screen = loadStudio({ stream, cameras: cams, screenShare: true });
++ await screen.api.flip();
++ assert.ok(screen.calls.switched.length === 0 && /sharing your screen/.test(screen.calls.toasts[0][1]));
++ });
++
++ await check('the camera buttons show once the camera runs (the live view used to hide them for good)', async () => {
++ const stream = { getAudioTracks: () => [], getVideoTracks: () => [], getTracks: () => [] };
++ const s = loadStudio({ stream });
++ s.tick();
++ assert.strictEqual(s.calls.synced, 1, 'a stream appearing re-syncs the live buttons');
++ const create = extract(bc, 'createNewStream');
++ assert.ok(/await startMediaCapture\(streamData\.id, \{ cameraId: createCamera, audioId: createAudio \}\);\s*\}\s*\/\/[^\n]*\n\s*syncBroadcastLiveButtonVisibility\(\);/.test(create), 'and again right after capture starts');
++ assert.strictEqual((bc.match(/function syncBroadcastLiveButtonVisibility\(/g) || []).length, 1, 'defined once');
++ });
++
++ await check('the live controls say what they do', async () => {
++ assert.ok(!/total viewers/.test(fragment) && /<strong id="bc-viewer-count">0<\/strong> watching/.test(fragment));
++ for (const gone of ['Switch Cam', 'Test TTS', 'Reset TTS', '> Viewer', '> Stats']) assert.ok(!fragment.includes(gone), `no "${gone}"`);
++ for (const label of ['Flip camera', 'Choose camera', 'Mute', 'Camera off', 'Stream health', 'Test chat voice', 'Skip spoken message', 'Media requests', 'Hide preview', 'Watch as viewer']) assert.ok(fragment.includes(label), `"${label}"`);
++ const status = extract(bc, 'updateBroadcastStatus');
++ assert.ok(!/toUpperCase/.test(status) && /connected:\s+\{ text: '',/.test(status), 'no protocol badge ("WEBRTC") while connected');
++ const rs = extract(bc, 'rsDetailLine');
++ assert.ok(!/passthrough`|to RS`/.test(rs) && /ms delay/.test(rs) && /reconnected once/.test(rs), 'RobotStreamer in words');
++ });
++
++ await check('the studio loads with the broadcast feature, after broadcast.js', async () => {
++ const js = features.broadcast.js;
++ assert.ok(js.indexOf('/js/broadcast-studio.js') > js.indexOf('/js/broadcast.js'));
++ assert.ok(/body\.bc-studio-on \.bc-video-container \{[^}]*position: fixed/.test(read('public/css/features/broadcast.css')));
++ });
++
++ if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
++ console.log('\nall passed');
++})();
ddiff --git a/test/broadcast-viewer-preview.test.js b/test/broadcast-viewer-preview.test.js
iindex ed78f238..633ee602 100644
---- a/test/broadcast-viewer-preview.test.js
++++ b/test/broadcast-viewer-preview.test.js
@@@ -106,7 +106,7 @@ console.log = log;
console.log('OK the slot segment resolves to the slot on air (the stream id resolved to another slot)');
const html = read('public/fragments/broadcast.html');
--assert.match(html, /onclick="openViewerPreview\(\)"[^>]*title="Open a popup showing exactly what viewers see"/, 'the live controls offer the Viewer popup');
++assert.match(html, /onclick="openViewerPreview\(\)"[^>]*title="Open your channel the way viewers see it"[^>]*>\s*<i[^>]*><\/i> Watch as viewer/, 'the live controls offer the viewer popup ("Watch as viewer")');
assert.match(bc, /function toggleBroadcastPreview\(\)/, 'next to the local-preview toggle');
console.log('OK the live controls carry the Viewer button');