openvibers/openvibe.live Commits

Calls show their pictures on every page and can share a screen (the call dock) (#317)

OP OpenVibers committed 21 days ago · 85ea75aad5 · 5 files, +259 −45

Changes

ddiff --git a/public/css/features/voice.css b/public/css/features/voice.css
nnew file mode 100644
iindex 00000000..31b1d536
---- /dev/null
++++ b/public/css/features/voice.css
@@@ -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; }
++}
ddiff --git a/public/features.json b/public/features.json
iindex e123484b..8abb1e33 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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": {
ddiff --git a/public/index.html b/public/index.html
iindex 8b41bc3f..a466e4da 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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>
ddiff --git a/public/js/call.js b/public/js/call.js
iindex cd44386b..edfa6259 100644
---- a/public/js/call.js
++++ b/public/js/call.js
@@@ -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 ends
forceMuted: false, // server-side force mute applied to us
forceCameraOff: false, // server-side force camera off applied to us
joined: 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 placeholder
const 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 {
ddiff --git a/public/js/voice-channels.js b/public/js/voice-channels.js
iindex 143718e7..f3174976 100644
---- a/public/js/voice-channels.js
++++ b/public/js/voice-channels.js
@@@ -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 name
const nameEl = document.getElementById('vc-mini-channel-name');
if (nameEl) {