diff --git a/.changeset/strange-pans-search.md b/.changeset/strange-pans-search.md new file mode 100644 index 0000000000..a7cddad79f --- /dev/null +++ b/.changeset/strange-pans-search.md @@ -0,0 +1,5 @@ +--- +'livekit-client': minor +--- + +Add support for detecting video element visibility in Document PiP (can be tested on the examples/demo) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 5ac892b205..4bf77c5398 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -278,6 +278,49 @@ const appActions = { await currentRoom.setE2EEEnabled(!currentRoom.isE2EEEnabled); }, + togglePiP: async () => { + if (window.documentPictureInPicture?.window) { + window.documentPictureInPicture?.window.close(); + return; + } + + const pipWindow = await window.documentPictureInPicture?.requestWindow(); + setButtonState('toggle-pip-button', 'Close PiP', true); + // Copy style sheets over from the initial document + // so that the views look the same. + [...document.styleSheets].forEach((styleSheet) => { + try { + const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join(''); + const style = document.createElement('style'); + style.textContent = cssRules; + pipWindow.document.head.appendChild(style); + } catch (e) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = styleSheet.type; + link.media = styleSheet.media; + link.href = styleSheet.href; + pipWindow.document.head.appendChild(link); + } + }); + // Move participant videos to the Picture-in-Picture window + const participantsArea = $('participants-area'); + const pipParticipantsArea = document.createElement('div'); + pipParticipantsArea.id = 'participants-area'; + pipWindow.document.body.append(pipParticipantsArea); + [...participantsArea.children].forEach((child) => pipParticipantsArea.append(child)); + + // Move participant videos back when the Picture-in-Picture window closes. + pipWindow.addEventListener('pagehide', (event) => { + setButtonState('toggle-pip-button', 'Open PiP', false); + if (currentRoom?.state === ConnectionState.Connected) + [...pipParticipantsArea.children].forEach((child) => participantsArea.append(child)); + }); + + // Close PiP when room disconnects + currentRoom.once('disconnected', (e) => window.documentPictureInPicture?.window.close()); + }, + ratchetE2EEKey: async () => { if (!currentRoom || !currentRoom.options.e2ee) { return; @@ -525,10 +568,10 @@ function appendLog(...args: any[]) { // updates participant UI function renderParticipant(participant: Participant, remove: boolean = false) { - const container = $('participants-area'); + const container = getParticipantsAreaElement(); if (!container) return; const { identity } = participant; - let div = $(`participant-${identity}`); + let div = container.querySelector(`#participant-${identity}`); if (!div && !remove) { div = document.createElement('div'); div.id = `participant-${identity}`; @@ -564,14 +607,14 @@ function renderParticipant(participant: Participant, remove: boolean = false) { `; container.appendChild(div); - const sizeElm = $(`size-${identity}`); - const videoElm = $(`video-${identity}`); + const sizeElm = container.querySelector(`#size-${identity}`); + const videoElm = container.querySelector(`#video-${identity}`); videoElm.onresize = () => { updateVideoSize(videoElm!, sizeElm!); }; } - const videoElm = $(`video-${identity}`); - const audioELm = $(`audio-${identity}`); + const videoElm = container.querySelector(`#video-${identity}`); + const audioELm = container.querySelector(`#audio-${identity}`); if (remove) { div?.remove(); if (videoElm) { @@ -586,12 +629,12 @@ function renderParticipant(participant: Participant, remove: boolean = false) { } // update properties - $(`name-${identity}`)!.innerHTML = participant.identity; + container.querySelector(`#name-${identity}`)!.innerHTML = participant.identity; if (participant instanceof LocalParticipant) { - $(`name-${identity}`)!.innerHTML += ' (you)'; + container.querySelector(`#name-${identity}`)!.innerHTML += ' (you)'; } - const micElm = $(`mic-${identity}`)!; - const signalElm = $(`signal-${identity}`)!; + const micElm = container.querySelector(`#mic-${identity}`)!; + const signalElm = container.querySelector(`#signal-${identity}`)!; const cameraPub = participant.getTrackPublication(Track.Source.Camera); const micPub = participant.getTrackPublication(Track.Source.Microphone); if (participant.isSpeaking) { @@ -601,7 +644,7 @@ function renderParticipant(participant: Participant, remove: boolean = false) { } if (participant instanceof RemoteParticipant) { - const volumeSlider = $(`volume-${identity}`); + const volumeSlider = container.querySelector(`#volume-${identity}`); volumeSlider.addEventListener('input', (ev) => { participant.setVolume(Number.parseFloat((ev.target as HTMLInputElement).value)); }); @@ -630,7 +673,7 @@ function renderParticipant(participant: Participant, remove: boolean = false) { cameraPub?.videoTrack?.attach(videoElm); } else { // clear information display - $(`size-${identity}`)!.innerHTML = ''; + container.querySelector(`#size-${identity}`)!.innerHTML = ''; if (cameraPub?.videoTrack) { // detach manually whenever possible cameraPub.videoTrack?.detach(videoElm); @@ -659,7 +702,7 @@ function renderParticipant(participant: Participant, remove: boolean = false) { micElm.innerHTML = ''; } - const e2eeElm = $(`e2ee-${identity}`)!; + const e2eeElm = container.querySelector(`#e2ee-${identity}`)!; if (participant.isEncrypted) { e2eeElm.className = 'e2ee-on'; e2eeElm.innerHTML = ''; @@ -734,9 +777,10 @@ function renderBitrate() { } const participants: Participant[] = [...currentRoom.remoteParticipants.values()]; participants.push(currentRoom.localParticipant); + const container = getParticipantsAreaElement(); for (const p of participants) { - const elm = $(`bitrate-${p.identity}`); + const elm = container.querySelector(`#bitrate-${p.identity}`); let totalBitrate = 0; for (const t of p.trackPublications.values()) { if (t.track) { @@ -745,7 +789,7 @@ function renderBitrate() { if (t.source === Track.Source.Camera) { if (t.videoTrack instanceof RemoteVideoTrack) { - const codecElm = $(`codec-${p.identity}`)!; + const codecElm = container.querySelector(`#codec-${p.identity}`)!; codecElm.innerHTML = t.videoTrack.getDecoderImplementation() ?? ''; } } @@ -760,6 +804,13 @@ function renderBitrate() { } } +function getParticipantsAreaElement() { + return ( + window.documentPictureInPicture?.window?.document.querySelector('#participants-area') || + $('participants-area') + ); +} + function updateVideoSize(element: HTMLVideoElement, target: HTMLElement) { target.innerHTML = `(${element.videoWidth}x${element.videoHeight})`; } @@ -795,6 +846,7 @@ function setButtonsForState(connected: boolean) { 'toggle-video-button', 'toggle-audio-button', 'share-screen-button', + 'toggle-pip-button', 'disconnect-ws-button', 'disconnect-room-button', 'flip-video-button', diff --git a/examples/demo/index.html b/examples/demo/index.html index 02c04a1f38..f00fd4e2f5 100644 --- a/examples/demo/index.html +++ b/examples/demo/index.html @@ -132,6 +132,15 @@

Livekit Sample App

> Share Screen +