openvibers/openvibe.live Commits
Calls show their pictures on every page and can share a screen (the call dock) (#317)
Changes
nnew file mode 100644@@@ -0,0 +1,50 @@++/* Voice calls (features.json "voice", with call.js and voice-channels.js).++ The call dock: off the Chat page, #vc-mini-bar carries the call's pictures (cameras, a shared screen) above its++ controls, so a call started from "Call this user" is seen on any page, not only on Chat. */++++.vc-mini-bar {++ flex-direction: column;++ align-items: stretch;++ gap: 8px;++ max-width: calc(100vw - 40px);++}++.vc-mini-row { display: flex; align-items: center; gap: 10px; min-width: 0; }++.vc-mini-channel { overflow: hidden; text-overflow: ellipsis; }++.vc-mini-btn[hidden] { display: none; }++.vc-mini-btn.is-on { background: var(--accent-strong, #3472d8); border-color: transparent; color: var(--on-accent-strong, #fff); }++++/* The pictures: a row of tiles, large or small (the size button), hidden when nobody shows video or the viewer hid them. */++.vc-dock-grid { display: none; gap: 6px; overflow-x: auto; overscroll-behavior: contain; --vc-dock-h: 180px; }++.vc-mini-bar.vc-dock-small .vc-dock-grid { --vc-dock-h: 100px; }++.vc-mini-bar.has-video:not(.vc-dock-off) .vc-dock-grid { display: flex; }++.vc-dock-grid .call-participant-tile {++ flex: none;++ height: var(--vc-dock-h);++ width: auto;++ min-height: 0;++ aspect-ratio: 1;++ font-size: 0.7rem;++}++.vc-dock-grid .call-participant-tile.has-video { aspect-ratio: 16 / 9; }++.vc-dock-grid .call-avatar { width: 46%; height: auto; aspect-ratio: 1; font-size: 1.1rem; }++.vc-dock-grid .call-participant-label { font-size: 0.68rem; padding: 2px 6px; }++++/* A picture is shown whole (a screen keeps its text, a camera its framing), on black. */++.vc-dock-grid .has-video .call-video,++.vc-participants-grid .has-video .call-video { object-fit: contain; background: #000; }++++/* On the Chat page, a tile with a picture is large enough to watch; avatars stay small. */++.vc-participants-grid .call-participant-tile.has-video {++ width: min(100%, 360px);++ height: auto;++ min-width: 0;++ aspect-ratio: 16 / 9;++}++.vc-participants-grid:has(.has-video) { max-height: none; }++++@media (max-width: 600px) {++ .vc-mini-bar { left: 16px; right: 16px; bottom: 72px; max-width: none; }++ .vc-mini-row { flex-wrap: wrap; gap: 6px 10px; }++ .vc-dock-grid { --vc-dock-h: 120px; }++ .vc-mini-bar.vc-dock-small .vc-dock-grid { --vc-dock-h: 80px; }++}@@@ -109,6 +109,9 @@]},"voice": {++ "css": [++ "/css/features/voice.css"++ ],"js": ["/js/call.js","/js/voice-channels.js"@@@ -127,7 +130,7 @@"vcSwitchCam","vcOnInputModeChange","vcOnPttKeyChange",-- "vcOnVadChange"++ "vcOnVadChange", "vcToggleScreen", "vcDockCycleSize", "vcOpenFullCall"]},"chatpage": {@@@ -996,17 +996,20 @@<span class="fab-label">Global</span></button>--<!-- ── Mini Voice Channel Indicator (shows when connected & not on Chat tab) ── -->--<div id="vc-mini-bar" class="vc-mini-bar" style="display:none">-- <span class="vc-mini-status"><i class="fa-solid fa-circle vc-mini-dot"></i> Voice Connected</span>-- <span class="vc-mini-channel" id="vc-mini-channel-name">Public</span>-- <div class="vc-mini-controls">-- <button class="vc-mini-btn" id="vc-mini-mute" onclick="vcToggleMute()" title="Mute">-- <i class="fa-solid fa-microphone"></i>-- </button>-- <button class="vc-mini-btn vc-mini-leave" onclick="vcLeave()" title="Disconnect">-- <i class="fa-solid fa-phone-slash"></i>-- </button>++<!-- ── Call dock (shows when connected & not on Chat tab): the call's pictures and its controls ── -->++<div id="vc-mini-bar" class="vc-mini-bar" role="region" aria-label="Call" style="display:none">++ <div id="vc-dock-grid" class="vc-dock-grid"></div>++ <div class="vc-mini-row">++ <span class="vc-mini-status"><i class="fa-solid fa-circle vc-mini-dot"></i> Voice Connected</span>++ <span class="vc-mini-channel" id="vc-mini-channel-name">Public</span>++ <div class="vc-mini-controls">++ <button class="vc-mini-btn" id="vc-mini-mute" onclick="vcToggleMute()" title="Mute"><i class="fa-solid fa-microphone"></i></button>++ <button class="vc-mini-btn" id="vc-mini-camera" onclick="vcToggleCamera()" title="Turn camera on" aria-pressed="false"><i class="fa-solid fa-video-slash"></i></button>++ <button class="vc-mini-btn" id="vc-mini-screen" onclick="vcToggleScreen()" title="Share your screen" aria-pressed="false"><i class="fa-solid fa-display"></i></button>++ <button class="vc-mini-btn" id="vc-mini-size" onclick="vcDockCycleSize()" title="Smaller pictures"><i class="fa-solid fa-down-left-and-up-right-to-center"></i></button>++ <button class="vc-mini-btn" onclick="vcOpenFullCall()" title="Open the whole call"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></button>++ <button class="vc-mini-btn vc-mini-leave" onclick="vcLeave()" title="Disconnect"><i class="fa-solid fa-phone-slash"></i></button>++ </div></div></div>@@@ -21,6 +21,8 @@ const callState = {peers: new Map(),muted: false,cameraOff: true,++ screenTrack: null, // the shared screen while it is the call's video (toggleCallScreen)++ cameraBeforeScreen: false, // the camera was on when the share began: it comes back when the share endsforceMuted: false, // server-side force mute applied to usforceCameraOff: false, // server-side force camera off applied to usjoined: false,@@@ -624,6 +626,8 @@ function leaveCall() {const wasBroadcast = callState.broadcastMode;const wasVcMode = callState.vcMode;callState.joined = false;++ callState.screenTrack = null; // its track stops with the local stream (_cleanupCall)++ callState.cameraBeforeScreen = false;callState.connecting = false;_cleanupCall();_renderCallUI();@@@ -738,12 +742,108 @@ function toggleCallMute() {}/** Toggle camera */++/** Send `track` as our video to every peer: into the existing video sender (no renegotiation), or a new one. */++async function _sendVideoToPeers(track) {++ for (const [peerId, peer] of callState.peers) {++ try {++ const existingSender = peer.pc.getSenders().find(s => s.track?.kind === 'video' || (s.track === null && s._isVideoSlot));++ if (existingSender) {++ await existingSender.replaceTrack(track);++ _optimizeCallSender(existingSender, track);++ } else {++ const sender = peer.pc.addTrack(track, callState.localStream);++ _optimizeCallSender(sender, track);++ }++ } catch (peerErr) {++ console.warn(`[Call] Failed to send video to peer ${peerId}:`, peerErr.message);++ }++ }++}++++/** Stop sending video to every peer. */++function _stopVideoToPeers() {++ for (const [peerId, peer] of callState.peers) {++ try {++ for (const sender of peer.pc.getSenders()) {++ if (sender.track && sender.track.kind === 'video') peer.pc.removeTrack(sender);++ }++ } catch (peerErr) {++ console.warn(`[Call] Failed to stop video to peer ${peerId}:`, peerErr.message);++ }++ }++}++++/** The shared screen stops being the call's video, here only (the caller decides what the peers get next). */++function _endScreenTrack() {++ const track = callState.screenTrack;++ if (!track) return false;++ callState.screenTrack = null;++ track.stop();++ callState.localStream?.removeTrack(track);++ callState.cameraOff = true;++ return true;++}++++/**++ * Share a screen, a window or a tab as this call's video, in place of the camera: the others see it where the camera++ * would be (the same video sender and the same camera-off signal, so nothing changes on the server). Stopping it here++ * or with the browser's own "Stop sharing" brings the camera back if it was on.++ */++async function toggleCallScreen() {++ if (callState.screenTrack) return await _stopCallScreen({ restoreCamera: true });++ if (!callState.joined || !callState.localStream) return;++ if (callState.callMode === 'mic') { _callSystemMessage('This call is voice only'); return; }++ if (callState.forceCameraOff) { _callSystemMessage('Your camera is disabled by the streamer'); return; }++ if (!navigator.mediaDevices?.getDisplayMedia) { _callSystemMessage('This browser cannot share its screen'); return; }++ let display;++ try {++ display = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15, max: 30 }, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false });++ } catch (err) {++ if (err?.name !== 'NotAllowedError' && err?.name !== 'AbortError') _callSystemMessage('Could not share the screen');++ return; // the picker was closed++ }++ const track = display.getVideoTracks()[0];++ if (!track) return;++ try { track.contentHint = 'detail'; } catch { /* older browsers */ }++ if (!callState.joined || !callState.localStream) { track.stop(); return; }++ callState.cameraBeforeScreen = !callState.cameraOff;++ // The camera pauses while the screen is shared.++ callState.localStream.getVideoTracks().forEach(t => { t.stop(); callState.localStream.removeTrack(t); });++ callState.localStream.addTrack(track);++ callState.screenTrack = track;++ callState.cameraOff = false;++ track.addEventListener('ended', () => { if (callState.screenTrack === track) _stopCallScreen({ restoreCamera: true }); });++ await _sendVideoToPeers(track);++ _sendCallMsg({ type: 'camera-off', cameraOff: false });++ _updateLocalPreview();++ _renderCallUI();++}++++async function _stopCallScreen({ restoreCamera = true } = {}) {++ const back = restoreCamera && callState.cameraBeforeScreen;++ callState.cameraBeforeScreen = false;++ if (!_endScreenTrack()) return;++ if (back) {++ await toggleCallCamera(); // the camera takes the video sender back and tells everyone++ if (!callState.cameraOff) return;++ }++ _stopVideoToPeers();++ _sendCallMsg({ type: 'camera-off', cameraOff: true });++ _updateLocalPreview();++ _renderCallUI();++}++async function toggleCallCamera() {if (callState.callMode === 'mic') return;if (callState.forceCameraOff) {_callSystemMessage('Your camera is disabled by the streamer');return;}++ // The camera button during a screen share switches to the camera (it takes over the same video sender).++ if (callState.screenTrack) {++ callState.cameraBeforeScreen = false;++ _endScreenTrack();++ }if (callState.cameraOff) {// Enable camera@@@ -758,25 +858,18 @@ async function toggleCallCamera() {callState.localStream.addTrack(camTrack);callState.cameraOff = false;-- // Add video track to all existing peer connections —-- // use replaceTrack if a video sender slot already exists (avoids renegotiation)-- for (const [peerId, peer] of callState.peers) {-- try {-- const existingSender = peer.pc.getSenders().find(s => s.track?.kind === 'video' || (s.track === null && s._isVideoSlot));-- if (existingSender) {-- await existingSender.replaceTrack(camTrack);-- _optimizeCallSender(existingSender, camTrack);-- } else {-- const sender = peer.pc.addTrack(camTrack, callState.localStream);-- _optimizeCallSender(sender, camTrack);-- }-- } catch (peerErr) {-- console.warn(`[Call] Failed to add camera track to peer ${peerId}:`, peerErr.message);-- }-- }++ // Into each peer's video sender when there is one (no renegotiation), else a new one.++ await _sendVideoToPeers(camTrack);} catch (err) {console.warn('[Call] Camera enable failed:', err);_callSystemMessage('Could not enable camera');++ // A screen share this was replacing has already stopped: the others are told there is no video.++ if (callState.cameraOff) {++ _stopVideoToPeers();++ _sendCallMsg({ type: 'camera-off', cameraOff: true });++ _updateLocalPreview();++ _renderCallUI();++ }return;}} else {@@@ -787,20 +880,7 @@ async function toggleCallCamera() {callState.localStream.removeTrack(t);});callState.cameraOff = true;---- // Remove video senders from all peer connections-- for (const [peerId, peer] of callState.peers) {-- try {-- const senders = peer.pc.getSenders();-- for (const sender of senders) {-- if (sender.track && sender.track.kind === 'video') {-- peer.pc.removeTrack(sender);-- }-- }-- } catch (peerErr) {-- console.warn(`[Call] Failed to remove camera track from peer ${peerId}:`, peerErr.message);-- }-- }++ _stopVideoToPeers();}if (callState.ws && callState.ws.readyState === WebSocket.OPEN) {@@@ -1574,8 +1654,10 @@ function _attachPeerVideoTrack(peerId, peer, track) {}peer.videoStream = peer.mediaStream;if (!peer.forceCameraOff) peer.cameraOff = false;++ // Muted: the voice plays from the peer's own audio element (its volume and local mute), never twice; a muted++ // video also starts without a click, where an unmuted one can be held back by the browser's autoplay rule.if (peer.videoEl) {-- _bindVideoElement(peer.videoEl, peer.videoStream, false);++ _bindVideoElement(peer.videoEl, peer.videoStream, true);}_syncCallVideoPopout();_scheduleRender();@@@ -1873,6 +1955,9 @@ function _createParticipantTile(opts) {// Video or avatar placeholderconst showVideo = _peerHasActiveVideo(opts);if (showVideo) {++ // A tile with a picture is drawn larger than an avatar (voice-channel grid and the floating call dock).++ tile.classList.add('has-video');++ if (opts.isLocal && callState.screenTrack) tile.classList.add('is-screen');let video;if (opts.isLocal) {// Cache the local video element to avoid flicker on re-render@@@ -1889,7 +1974,7 @@ function _createParticipantTile(opts) {} else if (opts.videoStream) {const peer = callState.peers.get(opts.peerId);if (peer) peer.videoEl = video;-- _bindVideoElement(video, opts.videoStream, false);++ _bindVideoElement(video, opts.videoStream, true); // the voice is the audio element's (see _attachPeerVideoTrack)}tile.appendChild(video);} else {@@@ -400,6 +400,36 @@ function vcToggleCamera() {toggleCallCamera(); // re-renders the panel itself when the track is up}++/** Share a screen, a window or a tab as the call's video (call.js toggleCallScreen); again to stop. */++function vcToggleScreen() {++ toggleCallScreen();++}++++/** The call dock's pictures: large, small, or hidden (the bar alone). Remembered on this device. */++const VC_DOCK_SIZES = ['large', 'small', 'off'];++function vcDockSize() {++ try { const v = localStorage.getItem('ov:vc-dock-size'); if (VC_DOCK_SIZES.includes(v)) return v; } catch { /* storage blocked */ }++ return 'large';++}++function vcDockCycleSize() {++ const next = VC_DOCK_SIZES[(VC_DOCK_SIZES.indexOf(vcDockSize()) + 1) % VC_DOCK_SIZES.length];++ try { localStorage.setItem('ov:vc-dock-size', next); } catch { /* storage blocked */ }++ vcUpdateMiniBar();++}++++/** The whole call (every tile, devices, settings) is on the Chat page. */++function vcOpenFullCall() {++ if (typeof navigate === 'function') navigate('/chat');++ else location.href = '/chat';++}++++/** Where the call's tiles go: the Chat page's grid there, the floating call dock (#vc-mini-bar) on every other page. */++function vcCallGrid() {++ const chatTab = document.getElementById('page-chat');++ const onChat = !!(chatTab && chatTab.classList.contains('active'));++ return document.getElementById(onChat ? 'vc-participants-grid' : 'vc-dock-grid') || document.getElementById('vc-participants-grid');++}++function vcToggleSettings() {vcState.settingsOpen = !vcState.settingsOpen;const panel = document.getElementById('vc-incall-settings');@@@ -446,6 +476,29 @@ function vcUpdateControlButtons() {: '<i class="fa-solid fa-video"></i>';camBtn.classList.toggle('active', camOff);}++ vcUpdateDockButtons();++}++++/** The call dock's camera and screen buttons follow the call (hidden in a voice-only call). */++function vcUpdateDockButtons() {++ const voiceOnly = callState.callMode === 'mic';++ const sharing = !!callState.screenTrack;++ const camOn = !callState.cameraOff && !callState.forceCameraOff && !sharing;++ const cam = document.getElementById('vc-mini-camera');++ if (cam) {++ cam.hidden = voiceOnly;++ cam.innerHTML = camOn ? '<i class="fa-solid fa-video"></i>' : '<i class="fa-solid fa-video-slash"></i>';++ cam.classList.toggle('is-on', camOn);++ cam.title = camOn ? 'Turn camera off' : 'Turn camera on';++ cam.setAttribute('aria-pressed', String(camOn));++ }++ const screen = document.getElementById('vc-mini-screen');++ if (screen) {++ screen.hidden = voiceOnly || !navigator.mediaDevices?.getDisplayMedia;++ screen.classList.toggle('is-on', sharing);++ screen.title = sharing ? 'Stop sharing your screen' : 'Share your screen';++ screen.setAttribute('aria-pressed', String(sharing));++ }}/* ── Participant Grid Rendering ────────────────────────────── */@@@ -472,8 +525,13 @@ function vcRenderUI() {vcUpdateControlButtons();-- const grid = document.getElementById('vc-participants-grid');++ const grid = vcCallGrid();if (!grid) return;++ // One place at a time: the tiles' video elements are cached on the peers, so the other grid is emptied.++ const other = document.getElementById(grid.id === 'vc-dock-grid' ? 'vc-participants-grid' : 'vc-dock-grid');++ if (other && other.firstChild) other.replaceChildren();++ const dock = document.getElementById('vc-mini-bar');++ if (dock) dock.classList.toggle('has-video', !callState.cameraOff || [...callState.peers.values()].some((p) => !p.cameraOff && !p.forceCameraOff && !p.localCameraOff));// Keyed by peer: a tile that is still there is swapped for its fresh version (video elements are// cached on the peer, so the picture does not restart), newcomers pop in, leavers fade out.const wanted = [];@@@ -683,8 +741,23 @@ function vcUpdateMiniBar() {const isOnChatTab = chatTab && chatTab.classList.contains('active');const isConnected = typeof callState !== 'undefined' && callState.joined && callState.vcMode;++ // The dock holds the call's tiles off the Chat page: when that changes, they are drawn again in the right place.++ const docked = !!(isConnected && !isOnChatTab);++ if (vcState.docked !== docked) {++ vcState.docked = docked;++ if (isConnected) vcRenderUI();++ }++if (isConnected && !isOnChatTab) {miniBar.style.display = '';++ const size = vcDockSize();++ for (const s of VC_DOCK_SIZES) miniBar.classList.toggle(`vc-dock-${s}`, s === size);++ const sizeBtn = document.getElementById('vc-mini-size');++ if (sizeBtn) {++ sizeBtn.innerHTML = size === 'off' ? '<i class="fa-solid fa-eye"></i>' : size === 'large' ? '<i class="fa-solid fa-down-left-and-up-right-to-center"></i>' : '<i class="fa-solid fa-eye-slash"></i>';++ sizeBtn.title = size === 'off' ? 'Show the pictures' : size === 'large' ? 'Smaller pictures' : 'Hide the pictures';++ }++ vcUpdateDockButtons();// Update channel nameconst nameEl = document.getElementById('vc-mini-channel-name');if (nameEl) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+/* Voice calls (features.json "voice", with call.js and voice-channels.js).
+ The call dock: off the Chat page, #vc-mini-bar carries the call's pictures (cameras, a shared screen) above its
+ controls, so a call started from "Call this user" is seen on any page, not only on Chat. */
+
+.vc-mini-bar {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ max-width: calc(100vw - 40px);
+}
+.vc-mini-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.vc-mini-channel { overflow: hidden; text-overflow: ellipsis; }
+.vc-mini-btn[hidden] { display: none; }
+.vc-mini-btn.is-on { background: var(--accent-strong, #3472d8); border-color: transparent; color: var(--on-accent-strong, #fff); }
+
+/* The pictures: a row of tiles, large or small (the size button), hidden when nobody shows video or the viewer hid them. */
+.vc-dock-grid { display: none; gap: 6px; overflow-x: auto; overscroll-behavior: contain; --vc-dock-h: 180px; }
+.vc-mini-bar.vc-dock-small .vc-dock-grid { --vc-dock-h: 100px; }
+.vc-mini-bar.has-video:not(.vc-dock-off) .vc-dock-grid { display: flex; }
+.vc-dock-grid .call-participant-tile {
+ flex: none;
+ height: var(--vc-dock-h);
+ width: auto;
+ min-height: 0;
+ aspect-ratio: 1;
+ font-size: 0.7rem;
+}
+.vc-dock-grid .call-participant-tile.has-video { aspect-ratio: 16 / 9; }
+.vc-dock-grid .call-avatar { width: 46%; height: auto; aspect-ratio: 1; font-size: 1.1rem; }
+.vc-dock-grid .call-participant-label { font-size: 0.68rem; padding: 2px 6px; }
+
+/* A picture is shown whole (a screen keeps its text, a camera its framing), on black. */
+.vc-dock-grid .has-video .call-video,
+.vc-participants-grid .has-video .call-video { object-fit: contain; background: #000; }
+
+/* On the Chat page, a tile with a picture is large enough to watch; avatars stay small. */
+.vc-participants-grid .call-participant-tile.has-video {
+ width: min(100%, 360px);
+ height: auto;
+ min-width: 0;
+ aspect-ratio: 16 / 9;
+}
+.vc-participants-grid:has(.has-video) { max-height: none; }
+
+@media (max-width: 600px) {
+ .vc-mini-bar { left: 16px; right: 16px; bottom: 72px; max-width: none; }
+ .vc-mini-row { flex-wrap: wrap; gap: 6px 10px; }
+ .vc-dock-grid { --vc-dock-h: 120px; }
+ .vc-mini-bar.vc-dock-small .vc-dock-grid { --vc-dock-h: 80px; }
+}
@@ -109,6 +109,9 @@
@@ -109,6 +109,9 @@
]
]
},
},
"voice": {
"voice": {
+ "css": [
+ "/css/features/voice.css"
+ ],
"js": [
"js": [
"/js/call.js",
"/js/call.js",
"/js/voice-channels.js"
"/js/voice-channels.js"
@@ -127,7 +130,7 @@
@@ -127,7 +130,7 @@
"vcSwitchCam",
"vcSwitchCam",
"vcOnInputModeChange",
"vcOnInputModeChange",
"vcOnPttKeyChange",
"vcOnPttKeyChange",
- "vcOnVadChange"
+ "vcOnVadChange", "vcToggleScreen", "vcDockCycleSize", "vcOpenFullCall"
]
]
},
},
"chatpage": {
"chatpage": {
@@ -996,17 +996,20 @@
@@ -996,17 +996,20 @@
<span class="fab-label">Global</span>
<span class="fab-label">Global</span>
</button>
</button>
-<!-- ── Mini Voice Channel Indicator (shows when connected & not on Chat tab) ── -->
-<div id="vc-mini-bar" class="vc-mini-bar" style="display:none">
- <span class="vc-mini-status"><i class="fa-solid fa-circle vc-mini-dot"></i> Voice Connected</span>
- <span class="vc-mini-channel" id="vc-mini-channel-name">Public</span>
- <div class="vc-mini-controls">
- <button class="vc-mini-btn" id="vc-mini-mute" onclick="vcToggleMute()" title="Mute">
- <i class="fa-solid fa-microphone"></i>
- </button>
- <button class="vc-mini-btn vc-mini-leave" onclick="vcLeave()" title="Disconnect">
- <i class="fa-solid fa-phone-slash"></i>
- </button>
+<!-- ── Call dock (shows when connected & not on Chat tab): the call's pictures and its controls ── -->
+<div id="vc-mini-bar" class="vc-mini-bar" role="region" aria-label="Call" style="display:none">
+ <div id="vc-dock-grid" class="vc-dock-grid"></div>
+ <div class="vc-mini-row">
+ <span class="vc-mini-status"><i class="fa-solid fa-circle vc-mini-dot"></i> Voice Connected</span>
+ <span class="vc-mini-channel" id="vc-mini-channel-name">Public</span>
+ <div class="vc-mini-controls">
+ <button class="vc-mini-btn" id="vc-mini-mute" onclick="vcToggleMute()" title="Mute"><i class="fa-solid fa-microphone"></i></button>
+ <button class="vc-mini-btn" id="vc-mini-camera" onclick="vcToggleCamera()" title="Turn camera on" aria-pressed="false"><i class="fa-solid fa-video-slash"></i></button>
+ <button class="vc-mini-btn" id="vc-mini-screen" onclick="vcToggleScreen()" title="Share your screen" aria-pressed="false"><i class="fa-solid fa-display"></i></button>
+ <button class="vc-mini-btn" id="vc-mini-size" onclick="vcDockCycleSize()" title="Smaller pictures"><i class="fa-solid fa-down-left-and-up-right-to-center"></i></button>
+ <button class="vc-mini-btn" onclick="vcOpenFullCall()" title="Open the whole call"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></button>
+ <button class="vc-mini-btn vc-mini-leave" onclick="vcLeave()" title="Disconnect"><i class="fa-solid fa-phone-slash"></i></button>
+ </div>
</div>
</div>
</div>
</div>
@@ -21,6 +21,8 @@ const callState = {
@@ -21,6 +21,8 @@ const callState = {
peers: new Map(),
peers: new Map(),
muted: false,
muted: false,
cameraOff: true,
cameraOff: true,
+ screenTrack: null, // the shared screen while it is the call's video (toggleCallScreen)
+ cameraBeforeScreen: false, // the camera was on when the share began: it comes back when the share ends
forceMuted: false, // server-side force mute applied to us
forceMuted: false, // server-side force mute applied to us
forceCameraOff: false, // server-side force camera off applied to us
forceCameraOff: false, // server-side force camera off applied to us
joined: false,
joined: false,
@@ -624,6 +626,8 @@ function leaveCall() {
@@ -624,6 +626,8 @@ function leaveCall() {
const wasBroadcast = callState.broadcastMode;
const wasBroadcast = callState.broadcastMode;
const wasVcMode = callState.vcMode;
const wasVcMode = callState.vcMode;
callState.joined = false;
callState.joined = false;
+ callState.screenTrack = null; // its track stops with the local stream (_cleanupCall)
+ callState.cameraBeforeScreen = false;
callState.connecting = false;
callState.connecting = false;
_cleanupCall();
_cleanupCall();
_renderCallUI();
_renderCallUI();
@@ -738,12 +742,108 @@ function toggleCallMute() {
@@ -738,12 +742,108 @@ function toggleCallMute() {
}
}
/** Toggle camera */
/** Toggle camera */
+/** Send `track` as our video to every peer: into the existing video sender (no renegotiation), or a new one. */
+async function _sendVideoToPeers(track) {
+ for (const [peerId, peer] of callState.peers) {
+ try {
+ const existingSender = peer.pc.getSenders().find(s => s.track?.kind === 'video' || (s.track === null && s._isVideoSlot));
+ if (existingSender) {
+ await existingSender.replaceTrack(track);
+ _optimizeCallSender(existingSender, track);
+ } else {
+ const sender = peer.pc.addTrack(track, callState.localStream);
+ _optimizeCallSender(sender, track);
+ }
+ } catch (peerErr) {
+ console.warn(`[Call] Failed to send video to peer ${peerId}:`, peerErr.message);
+ }
+ }
+}
+
+/** Stop sending video to every peer. */
+function _stopVideoToPeers() {
+ for (const [peerId, peer] of callState.peers) {
+ try {
+ for (const sender of peer.pc.getSenders()) {
+ if (sender.track && sender.track.kind === 'video') peer.pc.removeTrack(sender);
+ }
+ } catch (peerErr) {
+ console.warn(`[Call] Failed to stop video to peer ${peerId}:`, peerErr.message);
+ }
+ }
+}
+
+/** The shared screen stops being the call's video, here only (the caller decides what the peers get next). */
+function _endScreenTrack() {
+ const track = callState.screenTrack;
+ if (!track) return false;
+ callState.screenTrack = null;
+ track.stop();
+ callState.localStream?.removeTrack(track);
+ callState.cameraOff = true;
+ return true;
+}
+
+/**
+ * Share a screen, a window or a tab as this call's video, in place of the camera: the others see it where the camera
+ * would be (the same video sender and the same camera-off signal, so nothing changes on the server). Stopping it here
+ * or with the browser's own "Stop sharing" brings the camera back if it was on.
+ */
+async function toggleCallScreen() {
+ if (callState.screenTrack) return await _stopCallScreen({ restoreCamera: true });
+ if (!callState.joined || !callState.localStream) return;
+ if (callState.callMode === 'mic') { _callSystemMessage('This call is voice only'); return; }
+ if (callState.forceCameraOff) { _callSystemMessage('Your camera is disabled by the streamer'); return; }
+ if (!navigator.mediaDevices?.getDisplayMedia) { _callSystemMessage('This browser cannot share its screen'); return; }
+ let display;
+ try {
+ display = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15, max: 30 }, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false });
+ } catch (err) {
+ if (err?.name !== 'NotAllowedError' && err?.name !== 'AbortError') _callSystemMessage('Could not share the screen');
+ return; // the picker was closed
+ }
+ const track = display.getVideoTracks()[0];
+ if (!track) return;
+ try { track.contentHint = 'detail'; } catch { /* older browsers */ }
+ if (!callState.joined || !callState.localStream) { track.stop(); return; }
+ callState.cameraBeforeScreen = !callState.cameraOff;
+ // The camera pauses while the screen is shared.
+ callState.localStream.getVideoTracks().forEach(t => { t.stop(); callState.localStream.removeTrack(t); });
+ callState.localStream.addTrack(track);
+ callState.screenTrack = track;
+ callState.cameraOff = false;
+ track.addEventListener('ended', () => { if (callState.screenTrack === track) _stopCallScreen({ restoreCamera: true }); });
+ await _sendVideoToPeers(track);
+ _sendCallMsg({ type: 'camera-off', cameraOff: false });
+ _updateLocalPreview();
+ _renderCallUI();
+}
+
+async function _stopCallScreen({ restoreCamera = true } = {}) {
+ const back = restoreCamera && callState.cameraBeforeScreen;
+ callState.cameraBeforeScreen = false;
+ if (!_endScreenTrack()) return;
+ if (back) {
+ await toggleCallCamera(); // the camera takes the video sender back and tells everyone
+ if (!callState.cameraOff) return;
+ }
+ _stopVideoToPeers();
+ _sendCallMsg({ type: 'camera-off', cameraOff: true });
+ _updateLocalPreview();
+ _renderCallUI();
+}
+
async function toggleCallCamera() {
async function toggleCallCamera() {
if (callState.callMode === 'mic') return;
if (callState.callMode === 'mic') return;
if (callState.forceCameraOff) {
if (callState.forceCameraOff) {
_callSystemMessage('Your camera is disabled by the streamer');
_callSystemMessage('Your camera is disabled by the streamer');
return;
return;
}
}
+ // The camera button during a screen share switches to the camera (it takes over the same video sender).
+ if (callState.screenTrack) {
+ callState.cameraBeforeScreen = false;
+ _endScreenTrack();
+ }
if (callState.cameraOff) {
if (callState.cameraOff) {
// Enable camera
// Enable camera
@@ -758,25 +858,18 @@ async function toggleCallCamera() {
@@ -758,25 +858,18 @@ async function toggleCallCamera() {
callState.localStream.addTrack(camTrack);
callState.localStream.addTrack(camTrack);
callState.cameraOff = false;
callState.cameraOff = false;
- // Add video track to all existing peer connections —
- // use replaceTrack if a video sender slot already exists (avoids renegotiation)
- for (const [peerId, peer] of callState.peers) {
- try {
- const existingSender = peer.pc.getSenders().find(s => s.track?.kind === 'video' || (s.track === null && s._isVideoSlot));
- if (existingSender) {
- await existingSender.replaceTrack(camTrack);
- _optimizeCallSender(existingSender, camTrack);
- } else {
- const sender = peer.pc.addTrack(camTrack, callState.localStream);
- _optimizeCallSender(sender, camTrack);
- }
- } catch (peerErr) {
- console.warn(`[Call] Failed to add camera track to peer ${peerId}:`, peerErr.message);
- }
- }
+ // Into each peer's video sender when there is one (no renegotiation), else a new one.
+ await _sendVideoToPeers(camTrack);
} catch (err) {
} catch (err) {
console.warn('[Call] Camera enable failed:', err);
console.warn('[Call] Camera enable failed:', err);
_callSystemMessage('Could not enable camera');
_callSystemMessage('Could not enable camera');
+ // A screen share this was replacing has already stopped: the others are told there is no video.
+ if (callState.cameraOff) {
+ _stopVideoToPeers();
+ _sendCallMsg({ type: 'camera-off', cameraOff: true });
+ _updateLocalPreview();
+ _renderCallUI();
+ }
return;
return;
}
}
} else {
} else {
@@ -787,20 +880,7 @@ async function toggleCallCamera() {
@@ -787,20 +880,7 @@ async function toggleCallCamera() {
callState.localStream.removeTrack(t);
callState.localStream.removeTrack(t);
});
});
callState.cameraOff = true;
callState.cameraOff = true;
-
- // Remove video senders from all peer connections
- for (const [peerId, peer] of callState.peers) {
- try {
- const senders = peer.pc.getSenders();
- for (const sender of senders) {
- if (sender.track && sender.track.kind === 'video') {
- peer.pc.removeTrack(sender);
- }
- }
- } catch (peerErr) {
- console.warn(`[Call] Failed to remove camera track from peer ${peerId}:`, peerErr.message);
- }
- }
+ _stopVideoToPeers();
}
}
if (callState.ws && callState.ws.readyState === WebSocket.OPEN) {
if (callState.ws && callState.ws.readyState === WebSocket.OPEN) {
@@ -1574,8 +1654,10 @@ function _attachPeerVideoTrack(peerId, peer, track) {
@@ -1574,8 +1654,10 @@ function _attachPeerVideoTrack(peerId, peer, track) {
}
}
peer.videoStream = peer.mediaStream;
peer.videoStream = peer.mediaStream;
if (!peer.forceCameraOff) peer.cameraOff = false;
if (!peer.forceCameraOff) peer.cameraOff = false;
+ // Muted: the voice plays from the peer's own audio element (its volume and local mute), never twice; a muted
+ // video also starts without a click, where an unmuted one can be held back by the browser's autoplay rule.
if (peer.videoEl) {
if (peer.videoEl) {
- _bindVideoElement(peer.videoEl, peer.videoStream, false);
+ _bindVideoElement(peer.videoEl, peer.videoStream, true);
}
}
_syncCallVideoPopout();
_syncCallVideoPopout();
_scheduleRender();
_scheduleRender();
@@ -1873,6 +1955,9 @@ function _createParticipantTile(opts) {
@@ -1873,6 +1955,9 @@ function _createParticipantTile(opts) {
// Video or avatar placeholder
// Video or avatar placeholder
const showVideo = _peerHasActiveVideo(opts);
const showVideo = _peerHasActiveVideo(opts);
if (showVideo) {
if (showVideo) {
+ // A tile with a picture is drawn larger than an avatar (voice-channel grid and the floating call dock).
+ tile.classList.add('has-video');
+ if (opts.isLocal && callState.screenTrack) tile.classList.add('is-screen');
let video;
let video;
if (opts.isLocal) {
if (opts.isLocal) {
// Cache the local video element to avoid flicker on re-render
// Cache the local video element to avoid flicker on re-render
@@ -1889,7 +1974,7 @@ function _createParticipantTile(opts) {
@@ -1889,7 +1974,7 @@ function _createParticipantTile(opts) {
} else if (opts.videoStream) {
} else if (opts.videoStream) {
const peer = callState.peers.get(opts.peerId);
const peer = callState.peers.get(opts.peerId);
if (peer) peer.videoEl = video;
if (peer) peer.videoEl = video;
- _bindVideoElement(video, opts.videoStream, false);
+ _bindVideoElement(video, opts.videoStream, true); // the voice is the audio element's (see _attachPeerVideoTrack)
}
}
tile.appendChild(video);
tile.appendChild(video);
} else {
} else {
@@ -400,6 +400,36 @@ function vcToggleCamera() {
@@ -400,6 +400,36 @@ function vcToggleCamera() {
toggleCallCamera(); // re-renders the panel itself when the track is up
toggleCallCamera(); // re-renders the panel itself when the track is up
}
}
+/** Share a screen, a window or a tab as the call's video (call.js toggleCallScreen); again to stop. */
+function vcToggleScreen() {
+ toggleCallScreen();
+}
+
+/** The call dock's pictures: large, small, or hidden (the bar alone). Remembered on this device. */
+const VC_DOCK_SIZES = ['large', 'small', 'off'];
+function vcDockSize() {
+ try { const v = localStorage.getItem('ov:vc-dock-size'); if (VC_DOCK_SIZES.includes(v)) return v; } catch { /* storage blocked */ }
+ return 'large';
+}
+function vcDockCycleSize() {
+ const next = VC_DOCK_SIZES[(VC_DOCK_SIZES.indexOf(vcDockSize()) + 1) % VC_DOCK_SIZES.length];
+ try { localStorage.setItem('ov:vc-dock-size', next); } catch { /* storage blocked */ }
+ vcUpdateMiniBar();
+}
+
+/** The whole call (every tile, devices, settings) is on the Chat page. */
+function vcOpenFullCall() {
+ if (typeof navigate === 'function') navigate('/chat');
+ else location.href = '/chat';
+}
+
+/** Where the call's tiles go: the Chat page's grid there, the floating call dock (#vc-mini-bar) on every other page. */
+function vcCallGrid() {
+ const chatTab = document.getElementById('page-chat');
+ const onChat = !!(chatTab && chatTab.classList.contains('active'));
+ return document.getElementById(onChat ? 'vc-participants-grid' : 'vc-dock-grid') || document.getElementById('vc-participants-grid');
+}
+
function vcToggleSettings() {
function vcToggleSettings() {
vcState.settingsOpen = !vcState.settingsOpen;
vcState.settingsOpen = !vcState.settingsOpen;
const panel = document.getElementById('vc-incall-settings');
const panel = document.getElementById('vc-incall-settings');
@@ -446,6 +476,29 @@ function vcUpdateControlButtons() {
@@ -446,6 +476,29 @@ function vcUpdateControlButtons() {
: '<i class="fa-solid fa-video"></i>';
: '<i class="fa-solid fa-video"></i>';
camBtn.classList.toggle('active', camOff);
camBtn.classList.toggle('active', camOff);
}
}
+ vcUpdateDockButtons();
+}
+
+/** The call dock's camera and screen buttons follow the call (hidden in a voice-only call). */
+function vcUpdateDockButtons() {
+ const voiceOnly = callState.callMode === 'mic';
+ const sharing = !!callState.screenTrack;
+ const camOn = !callState.cameraOff && !callState.forceCameraOff && !sharing;
+ const cam = document.getElementById('vc-mini-camera');
+ if (cam) {
+ cam.hidden = voiceOnly;
+ cam.innerHTML = camOn ? '<i class="fa-solid fa-video"></i>' : '<i class="fa-solid fa-video-slash"></i>';
+ cam.classList.toggle('is-on', camOn);
+ cam.title = camOn ? 'Turn camera off' : 'Turn camera on';
+ cam.setAttribute('aria-pressed', String(camOn));
+ }
+ const screen = document.getElementById('vc-mini-screen');
+ if (screen) {
+ screen.hidden = voiceOnly || !navigator.mediaDevices?.getDisplayMedia;
+ screen.classList.toggle('is-on', sharing);
+ screen.title = sharing ? 'Stop sharing your screen' : 'Share your screen';
+ screen.setAttribute('aria-pressed', String(sharing));
+ }
}
}
/* ── Participant Grid Rendering ────────────────────────────── */
/* ── Participant Grid Rendering ────────────────────────────── */
@@ -472,8 +525,13 @@ function vcRenderUI() {
@@ -472,8 +525,13 @@ function vcRenderUI() {
vcUpdateControlButtons();
vcUpdateControlButtons();
- const grid = document.getElementById('vc-participants-grid');
+ const grid = vcCallGrid();
if (!grid) return;
if (!grid) return;
+ // One place at a time: the tiles' video elements are cached on the peers, so the other grid is emptied.
+ const other = document.getElementById(grid.id === 'vc-dock-grid' ? 'vc-participants-grid' : 'vc-dock-grid');
+ if (other && other.firstChild) other.replaceChildren();
+ const dock = document.getElementById('vc-mini-bar');
+ if (dock) dock.classList.toggle('has-video', !callState.cameraOff || [...callState.peers.values()].some((p) => !p.cameraOff && !p.forceCameraOff && !p.localCameraOff));
// Keyed by peer: a tile that is still there is swapped for its fresh version (video elements are
// Keyed by peer: a tile that is still there is swapped for its fresh version (video elements are
// cached on the peer, so the picture does not restart), newcomers pop in, leavers fade out.
// cached on the peer, so the picture does not restart), newcomers pop in, leavers fade out.
const wanted = [];
const wanted = [];
@@ -683,8 +741,23 @@ function vcUpdateMiniBar() {
@@ -683,8 +741,23 @@ function vcUpdateMiniBar() {
const isOnChatTab = chatTab && chatTab.classList.contains('active');
const isOnChatTab = chatTab && chatTab.classList.contains('active');
const isConnected = typeof callState !== 'undefined' && callState.joined && callState.vcMode;
const isConnected = typeof callState !== 'undefined' && callState.joined && callState.vcMode;
+ // The dock holds the call's tiles off the Chat page: when that changes, they are drawn again in the right place.
+ const docked = !!(isConnected && !isOnChatTab);
+ if (vcState.docked !== docked) {
+ vcState.docked = docked;
+ if (isConnected) vcRenderUI();
+ }
+
if (isConnected && !isOnChatTab) {
if (isConnected && !isOnChatTab) {
miniBar.style.display = '';
miniBar.style.display = '';
+ const size = vcDockSize();
+ for (const s of VC_DOCK_SIZES) miniBar.classList.toggle(`vc-dock-${s}`, s === size);
+ const sizeBtn = document.getElementById('vc-mini-size');
+ if (sizeBtn) {
+ sizeBtn.innerHTML = size === 'off' ? '<i class="fa-solid fa-eye"></i>' : size === 'large' ? '<i class="fa-solid fa-down-left-and-up-right-to-center"></i>' : '<i class="fa-solid fa-eye-slash"></i>';
+ sizeBtn.title = size === 'off' ? 'Show the pictures' : size === 'large' ? 'Smaller pictures' : 'Hide the pictures';
+ }
+ vcUpdateDockButtons();
// Update channel name
// Update channel name
const nameEl = document.getElementById('vc-mini-channel-name');
const nameEl = document.getElementById('vc-mini-channel-name');
if (nameEl) {
if (nameEl) {