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)
Changes
@@@ -46,15 +46,28 @@ OpenVibe.Live supports four live broadcast methods, each suited to different use### Live ControlsWhen 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 ChatThe broadcast page includes an embedded chat sidebar with:@@@ -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@@@ -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; }++}@@@ -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": {@@@ -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">nnew file mode 100644@@@ -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 };++})();@@@ -269,13 +269,13 @@ function updateRsRestreamSlotUI() {// Status + action buttonshtml += '<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';}}nnew file mode 100644@@@ -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');++})();@@@ -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');
@@ -46,15 +46,28 @@ OpenVibe.Live supports four live broadcast methods, each suited to different use
@@ -46,15 +46,28 @@ OpenVibe.Live supports four live broadcast methods, each suited to different use
### Live Controls
### Live Controls
When streaming via WebRTC browser, you have access to:
When streaming via WebRTC browser, you have access to:
- **Stop Stream** — End the broadcast
- **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
- **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
### Stream Chat
The broadcast page includes an embedded chat sidebar with:
The broadcast page includes an embedded chat sidebar with:
@@ -23,12 +23,22 @@ You don't need any of the following:
@@ -23,12 +23,22 @@ You don't need any of the following:
## What you can do while live
## 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.
- **Mute** your microphone or turn off your camera without ending the stream.
- **Watch your stream's health:** bitrate, frame rate and resolution.
- **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.
- **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.
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
## When to use OBS instead
@@ -2452,3 +2452,197 @@
@@ -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--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; }
.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; }
+}
@@ -213,7 +213,8 @@
@@ -213,7 +213,8 @@
"/js/broadcast-devices.js",
"/js/broadcast-devices.js",
"/js/call.js",
"/js/call.js",
"/js/broadcast-workspace.js",
"/js/broadcast-workspace.js",
- "/js/broadcast.js"
+ "/js/broadcast.js",
+ "/js/broadcast-studio.js"
]
]
},
},
"pastes": {
"pastes": {
@@ -663,7 +663,7 @@ WantedBy=multi-user.target</code>
@@ -663,7 +663,7 @@ WantedBy=multi-user.target</code>
<!-- Info bar -->
<!-- Info bar -->
<div class="bc-info-bar" id="bc-info-bar" style="display:none">
<div class="bc-info-bar" id="bc-info-bar" style="display:none">
<div class="bc-info-item">
<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>
<div class="bc-info-item" id="bc-rs-viewer-count" style="display:none">
<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
<i class="fa-solid fa-robot"></i> <strong>0</strong> on RS
@@ -683,38 +683,47 @@ WantedBy=multi-user.target</code>
@@ -683,38 +683,47 @@ WantedBy=multi-user.target</code>
<button class="bc-ctrl-btn bc-ctrl-btn-danger" id="bc-toggle-stream" onclick="stopBroadcast()">
<button class="bc-ctrl-btn bc-ctrl-btn-danger" id="bc-toggle-stream" onclick="stopBroadcast()">
<i class="fa-solid fa-stop"></i> Stop Stream
<i class="fa-solid fa-stop"></i> Stop Stream
</button>
</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()">
<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>
<button class="bc-ctrl-btn" id="bc-btn-screenshare" onclick="toggleScreenShare()">
<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>
<button class="bc-ctrl-btn" id="bc-btn-cam-overlay" onclick="toggleCameraOverlay()" style="display:none">
<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>
<button class="bc-ctrl-btn" id="bc-btn-toggle-mic" onclick="toggleScreenShareMic()" style="display:none">
<button class="bc-ctrl-btn" id="bc-btn-toggle-mic" onclick="toggleScreenShareMic()" style="display:none">
<i class="fa-solid fa-microphone"></i> Mic
<i class="fa-solid fa-microphone"></i> Mic
</button>
</button>
<button class="bc-ctrl-btn" onclick="toggleBroadcastStats()">
<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>
- <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>
- <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>
<button class="bc-ctrl-btn" id="bc-btn-clip" onclick="toggleBroadcastClipPanel()">
<button class="bc-ctrl-btn" id="bc-btn-clip" onclick="toggleBroadcastClipPanel()">
<i class="fa-solid fa-scissors"></i> Clip
<i class="fa-solid fa-scissors"></i> Clip
</button>
</button>
<button class="bc-ctrl-btn" id="bc-btn-media-pip" onclick="toggleMediaPip()">
<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>
- <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>
- <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>
</button>
</div>
</div>
@@ -1186,7 +1195,7 @@ WantedBy=multi-user.target</code>
@@ -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-panel" role="dialog" aria-modal="true" aria-labelledby="bc-camera-sheet-title">
<div class="bc-camera-sheet-header">
<div class="bc-camera-sheet-header">
<div>
<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>
<p id="bc-camera-sheet-subtitle">Pick the exact camera you want to use live.</p>
</div>
</div>
<button class="bc-camera-sheet-close" type="button" onclick="closeCameraSwitcher()" aria-label="Close camera switcher">
<button class="bc-camera-sheet-close" type="button" onclick="closeCameraSwitcher()" aria-label="Close camera switcher">
new file mode 100644
new file mode 100644
@@ -0,0 +1,402 @@
@@ -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 };
+})();
@@ -269,13 +269,13 @@ function updateRsRestreamSlotUI() {
@@ -269,13 +269,13 @@ function updateRsRestreamSlotUI() {
// Status + action buttons
// Status + action buttons
html += '<div class="bc-rs-status-row">';
html += '<div class="bc-rs-status-row">';
if (isLive) {
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>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Stop</button>`;
} else if (isStarting) {
} else if (isStarting) {
html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Connecting…</span>`;
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>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else if (hasFailed) {
} 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>`;
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) {
} else if (slotId && slotSs?.localStream) {
// Slot assigned but not started yet (could be reconnecting)
// Slot assigned but not started yet (could be reconnecting)
@@ -284,12 +284,12 @@ function updateRsRestreamSlotUI() {
@@ -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 += `<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>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else {
} 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>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="startOrRetryRsRestream()"><i class="fa-solid fa-play"></i> Start</button>`;
}
}
} else {
} else {
// No slot assigned
// 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 += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="startOrRetryRsRestream()"><i class="fa-solid fa-play"></i> Start</button>`;
}
}
html += '</div>';
html += '</div>';
@@ -1557,6 +1557,8 @@ async function createNewStream() {
@@ -1557,6 +1557,8 @@ async function createNewStream() {
} else {
} else {
await startMediaCapture(streamData.id, { cameraId: createCamera, audioId: createAudio });
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);
connectSignaling(streamData.id);
maybeStartRsRestream(streamData.id);
maybeStartRsRestream(streamData.id);
@@ -3102,15 +3104,14 @@ function rsDetailLine(rs) {
@@ -3102,15 +3104,14 @@ function rsDetailLine(rs) {
const bits = [];
const bits = [];
const now = Date.now();
const now = Date.now();
if (rs.state === 'live') {
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) {
- 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.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.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') {
} else if (rs.state === 'starting') {
bits.push('connecting to RobotStreamer…');
bits.push('connecting to RobotStreamer…');
} else if (rs.state === 'restarting') {
} else if (rs.state === 'restarting') {
@@ -4261,13 +4262,12 @@ let _platformViewerBreakdown = [];
@@ -4261,13 +4262,12 @@ let _platformViewerBreakdown = [];
function updateBroadcastStatus(state) {
function updateBroadcastStatus(state) {
const el = document.getElementById('bc-connection-status'); if (!el) return;
const el = document.getElementById('bc-connection-status'); if (!el) return;
// Determine streaming method label from active stream
// 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 = {
const map = {
- connected: { text: proto, cls: 'bc-status-good' },
+ connected: { text: '', cls: 'bc-status-good is-quiet' },
checking: { text: 'Connecting…', cls: 'bc-status-warn' },
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' },
failed: { text: 'Failed', cls: 'bc-status-bad' },
error: { text: 'Error', cls: 'bc-status-bad' },
error: { text: 'Error', cls: 'bc-status-bad' },
new: { text: 'Starting…', cls: 'bc-status-warn' },
new: { text: 'Starting…', cls: 'bc-status-warn' },
@@ -5073,27 +5073,6 @@ function syncBroadcastLiveButtonVisibility() {
@@ -5073,27 +5073,6 @@ function syncBroadcastLiveButtonVisibility() {
// Mic / Cam PiP are further gated by _syncScreenShareLiveControls()
// 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'; }
function toggleBroadcastStats() { const el = document.getElementById('bc-stats-overlay'); if (el) el.style.display = el.style.display === 'none' ? 'flex' : 'none'; }
/* ── Group Call Controls ───────────────────────────────────────── */
/* ── Group Call Controls ───────────────────────────────────────── */
@@ -6184,10 +6163,10 @@ function toggleBroadcastPreview() {
@@ -6184,10 +6163,10 @@ function toggleBroadcastPreview() {
if (!video || !btn) return;
if (!video || !btn) return;
if (video.style.display === 'none') {
if (video.style.display === 'none') {
video.style.display = '';
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 {
} else {
video.style.display = 'none';
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';
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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');
+})();
@@ -106,7 +106,7 @@ console.log = log;
@@ -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)');
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');
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');
assert.match(bc, /function toggleBroadcastPreview\(\)/, 'next to the local-preview toggle');
console.log('OK the live controls carry the Viewer button');
console.log('OK the live controls carry the Viewer button');