Skip to content

Commit 4cd76e1

Browse files
[mirotalkc2c] - feat(screen): show camera as picture-in-picture over the shared screen
1 parent e5568f8 commit 4cd76e1

11 files changed

Lines changed: 807 additions & 9 deletions

File tree

‎.env.template‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# ====================================================
2-
# MiroTalk C2C v.1.4.83 - Environment Configuration
2+
# MiroTalk C2C v.1.4.84 - Environment Configuration
33
# ====================================================
44

55
# App environment

‎README.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,23 @@ Open [http://localhost:8080](http://localhost:8080) - done!
7373
<details>
7474
<summary>🖼️ Camera backgrounds</summary>
7575

76-
In **Settings > Video > Background**, select **Off**, **Blur**, or **Image**. PNG, JPEG, and WebP images up to 10 MB are processed locally in your browser and are not uploaded. Effects appear in the camera preview, outgoing camera video, and camera recordings. Screen sharing is unaffected; the selected effect resumes when returning to the camera. Background changes are blocked while recording.
76+
In **Settings > Video > Background**, select **Off**, **Blur**, or **Image**. PNG, JPEG, and WebP images up to 10 MB are processed locally in your browser and are not uploaded. Effects appear in the camera preview, outgoing camera video, and camera recordings. Screen sharing is unaffected unless the camera is shown over the shared screen (see below); the selected effect resumes when returning to the camera. Background changes are blocked while recording.
7777

7878
Effects require WebGL2 and canvas stream capture; blur also requires canvas filter support. MediaPipe Tasks Vision 0.10.21 and its WASM files are lazy-loaded from jsDelivr, and the selfie segmentation model (version 1) is loaded from Google Storage. These hosts must be reachable and allowed by any custom CSP, including WASM execution. Processing failures fall back to the original camera.
7979

8080
Effect output is capped at 1280 x 720 and 15 FPS. Off uses the original camera without processing. Performance varies by device, and background tabs may throttle rendering. Image selections are not persisted across page reloads.
8181

8282
</details>
8383

84+
<details>
85+
<summary>🎥 Camera while sharing screen</summary>
86+
87+
In **Settings > Video > Camera while sharing screen**, pick a corner (**Bottom right**, **Bottom left**, **Top right**, **Top left**) to show your camera as a picture-in-picture bubble over your shared screen, or **Off** (default) to share the screen only. The bubble is drawn in your browser and sent together with the screen as a single video, so the other participant and recordings see it without any extra setup. The corner can be changed while sharing; turning it on mid-share applies the next time you share.
88+
89+
The bubble is shown only if your camera is on when you start sharing, and keeps the selected camera background effect. The composed video is capped at 1920 x 1080 and 30 FPS, uses a bit more CPU than plain screen sharing, and needs canvas stream capture (it falls back to the plain screen when unavailable). Desktop browsers only.
90+
91+
</details>
92+
8493
<details>
8594
<summary>🐳 Docker</summary>
8695

‎backend/server.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @link Live demo: https://c2c.mirotalk.com
88
* @license Open source: AGPLv3 | Commercial/closed source (official channel): https://docs.mirotalk.com/sites/c2c/
99
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
10-
* @version 1.4.83
10+
* @version 1.4.84
1111
*/
1212

1313
require('dotenv').config();

‎frontend/html/client.html‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
<script defer src="../js/fix-webm-duration.js"></script>
5555
<script defer src="../js/recording.js"></script>
5656
<script defer src="../js/background-effects.js"></script>
57+
<script defer src="../js/screen-camera.js"></script>
5758
<script defer src="../js/client.js"></script>
5859
<script defer src="../js/translate.js"></script>
5960
<script defer src="../js/noise-processor.js"></script>
@@ -273,6 +274,16 @@ <h1>Preparing your call</h1>
273274
</button>
274275
<div id="backgroundImageName" class="background-image-name" hidden></div>
275276
</div>
277+
<div id="screenCameraDiv" class="settings-field" hidden>
278+
<label for="screenCameraSelect">Camera while sharing screen</label>
279+
<select id="screenCameraSelect">
280+
<option value="off">Off</option>
281+
<option value="bottom-right">Bottom right</option>
282+
<option value="bottom-left">Bottom left</option>
283+
<option value="top-right">Top right</option>
284+
<option value="top-left">Top left</option>
285+
</select>
286+
</div>
276287
</div>
277288

278289
<div class="settings-section">

‎frontend/js/client.js‎

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @link Live demo: https://c2c.mirotalk.com
88
* @license Open source: AGPLv3 | Commercial/closed source (official channel): https://docs.mirotalk.com/sites/c2c/
99
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
10-
* @version 1.4.83
10+
* @version 1.4.84
1111
*/
1212

1313
const savedTheme = window.localStorage.getItem('home-theme');
@@ -62,6 +62,8 @@ const backgroundEffectLoading = document.getElementById('backgroundEffectLoading
6262
const backgroundImageInput = document.getElementById('backgroundImageInput');
6363
const backgroundImageBtn = document.getElementById('backgroundImageBtn');
6464
const backgroundImageName = document.getElementById('backgroundImageName');
65+
const screenCameraDiv = document.getElementById('screenCameraDiv');
66+
const screenCameraSelect = document.getElementById('screenCameraSelect');
6567
const videoQualitySelect = document.getElementById('videoQualitySelect');
6668
const videoFpsDiv = document.getElementById('videoFpsDiv');
6769
const videoFpsSelect = document.getElementById('videoFpsSelect');
@@ -210,6 +212,7 @@ let noiseSuppressionRequest = 0;
210212
let noiseSuppressionConstraints = Promise.resolve();
211213
let microphoneRequest = 0;
212214
let cameraEffects = null;
215+
let screenComposer = null; // draws the camera over the shared screen, when enabled
213216
// Devices actually in use, to restore the selects when a swap fails or a fallback device was opened
214217
let activeVideoDeviceId = null;
215218
let activeAudioDeviceId = null;
@@ -1399,6 +1402,10 @@ function handleEvents() {
13991402
screenShareBtn.onclick = async () => {
14001403
await toggleScreenSharing();
14011404
};
1405+
screenCameraDiv.hidden = !ScreenCameraComposer.supported();
1406+
screenCameraSelect.onchange = () => {
1407+
setScreenCameraPosition(screenCameraSelect.value);
1408+
};
14021409
} else {
14031410
elemDisplay(initScreenShareBtn, false);
14041411
elemDisplay(screenShareBtn, false);
@@ -1664,6 +1671,7 @@ function handleBackgroundError(processor, error) {
16641671
const track = processor.cameraTrack;
16651672
backgroundEffectSelect.value = 'off';
16661673
if (localMediaStream?.getVideoTracks()[0] === processor.outputTrack) attachCameraBackgroundTrack(track);
1674+
if (screenComposer?.cameraTrack === processor.outputTrack) screenComposer.setCameraTrack(track);
16671675
stopCameraEffects(false);
16681676
updateBackgroundControls();
16691677
popupMessage('warning', 'Camera background', 'Background effects unavailable. Continuing without effects.');
@@ -1807,7 +1815,14 @@ async function toggleScreenSharing() {
18071815
newStream,
18081816
'✅ Noise suppression applied to screen sharing audio'
18091817
);
1818+
handleScreenTrackEnded(newStream.getVideoTracks()[0]);
1819+
newStream = await composeScreenWithCamera(newStream);
18101820
} else {
1821+
if (screenComposer) {
1822+
// Release the camera shown over the screen before opening it again
1823+
stopScreenComposer();
1824+
stopCameraEffects();
1825+
}
18111826
newStream = await getBestUserMedia(constraints);
18121827
newStream = await applyNoiseSuppressionWithLogging(
18131828
newStream,
@@ -1816,8 +1831,11 @@ async function toggleScreenSharing() {
18161831
}
18171832

18181833
if (newStream) {
1819-
stopCameraEffects();
1820-
localMediaStream?.getVideoTracks().forEach((track) => track.stop());
1834+
// The camera keeps running while it is drawn over the screen
1835+
if (!screenComposer) {
1836+
stopCameraEffects();
1837+
localMediaStream?.getVideoTracks().forEach((track) => track.stop());
1838+
}
18211839
if (isScreenStreaming) {
18221840
if (hasVideoTrack(newStream)) camera = detectCameraFacingMode(newStream);
18231841
newStream = await prepareCameraBackground(newStream);
@@ -1851,6 +1869,63 @@ async function toggleScreenSharing() {
18511869
}
18521870
}
18531871

1872+
/**
1873+
* Go back to the camera when sharing is stopped from the browser UI ("Stop sharing")
1874+
* @param {MediaStreamTrack} track the screen video track
1875+
*/
1876+
function handleScreenTrackEnded(track) {
1877+
track?.addEventListener(
1878+
'ended',
1879+
() => {
1880+
if (isScreenStreaming) toggleScreenSharing();
1881+
},
1882+
{ once: true }
1883+
);
1884+
}
1885+
1886+
function getScreenCameraPosition() {
1887+
const position = localStorageConfig.video.settings.screen_camera;
1888+
return typeof ScreenCameraComposer !== 'undefined' && ScreenCameraComposer.isPosition(position) ? position : 'off';
1889+
}
1890+
1891+
function setScreenCameraPosition(position) {
1892+
localStorageConfig.video.settings.screen_camera = position;
1893+
saveLocalStorageConfig();
1894+
if (screenComposer) {
1895+
screenComposer.setPosition(position);
1896+
} else if (isScreenStreaming && position !== 'off') {
1897+
popupMessage('toast', 'Camera while sharing screen', 'Applies the next time you share your screen', 'top');
1898+
}
1899+
}
1900+
1901+
/**
1902+
* Draw the camera over the shared screen, if enabled and the camera is on
1903+
* @param {MediaStream} screenStream
1904+
* @returns {Promise<MediaStream>} the composed stream, or the screen stream as is
1905+
*/
1906+
async function composeScreenWithCamera(screenStream) {
1907+
const position = getScreenCameraPosition();
1908+
const cameraTrack = localMediaStream?.getVideoTracks()[0];
1909+
if (position === 'off' || !isMyVideoActiveBefore || cameraTrack?.readyState !== 'live') return screenStream;
1910+
if (!ScreenCameraComposer.supported()) return screenStream;
1911+
const composer = new ScreenCameraComposer(position);
1912+
try {
1913+
const stream = await composer.start(screenStream, cameraTrack);
1914+
screenComposer = composer;
1915+
return stream;
1916+
} catch (err) {
1917+
console.warn('[Warning] Unable to show the camera over the shared screen', err);
1918+
composer.stop(false);
1919+
return screenStream;
1920+
}
1921+
}
1922+
1923+
function stopScreenComposer() {
1924+
const composer = screenComposer;
1925+
screenComposer = null;
1926+
composer?.stop();
1927+
}
1928+
18541929
function changeCamera(deviceId = false) {
18551930
if (backgroundEffectsBusy || isScreenStreaming) return;
18561931
if (recording && recording.isStreamRecording()) {
@@ -2353,6 +2428,7 @@ function loadLocalStorageConfig() {
23532428
switchNoiseSuppression.checked = localStorageConfig?.audio?.settings?.noise_suppression ?? false;
23542429
switchMaxVideoQuality.checked = localStorageConfig.video.settings.best_quality;
23552430
switchKeepAspectRatio.checked = localStorageConfig.video.settings.aspect_ratio;
2431+
screenCameraSelect.value = getScreenCameraPosition();
23562432
if (localStorageConfig.video.init.hide) initHideMeBtn.click();
23572433
if (
23582434
!localStorageConfig.video.settings.best_quality &&

0 commit comments

Comments
 (0)