/** Video-only PiP adapter. The existing element/stream remains app-owned. * No URLs, credentials, tracks or keys cross the dashboard message boundary. */ export function attachVideoPip({ button, status, getVideo, dashboardOrigin, win = window, doc = document }) { const origin = new URL(dashboardOrigin).origin let session = null, native = false, selected = null, busy = false, timer = null let reserved = false, reserving = false let acquisition = null let pendingEntry = false, pendingExit = false, exitVideo = null, disposed = false let failedExit = null let leaveListener = null, fullscreenListener = null, terminalListener = null const say = message => { status.textContent = message } const send = (action, values = {}, owner = { session, acquisition }) => { if (owner.session) win.parent.postMessage({ type: 'archipelago:video-request', version: 1, ...owner, action, ...values }, origin) } function finish() { clearTimeout(timer); timer = null; busy = false; button.disabled = !disposed && pendingExit } function clear() { finish() selected?.removeEventListener('play', publish) selected?.removeEventListener('pause', publish) selected?.removeEventListener('ended', terminalListener) selected?.removeEventListener('error', terminalListener) selected?.removeEventListener('enterpictureinpicture', leaveListener) selected?.removeEventListener('leavepictureinpicture', leaveListener) if (fullscreenListener) doc.removeEventListener('fullscreenchange', fullscreenListener) leaveListener = null; fullscreenListener = null; terminalListener = null; selected = null } function publish() { if (selected) send('state', { playing: !selected.paused && !selected.ended }) } function leave() { clear() if (!pendingExit) say('Returned to the call. Picture-in-picture does not end the call.') } async function exitOwned(video) { if (!video || pendingExit) return const exits = [['pictureInPictureElement', 'exitPictureInPicture'], ['fullscreenElement', 'exitFullscreen']] .filter(([field]) => doc[field] === video) if (!exits.length) return pendingExit = true; exitVideo = video if (!disposed) { button.disabled = true; say('Leaving picture-in-picture. Your call remains connected.') } // Document-global exits must settle before any new presentation request. // Do not queue a future entry: fullscreen still needs a fresh user gesture. const results = await Promise.allSettled(exits.map(async ([, method]) => { if (typeof doc[method] !== 'function') throw Error('Presentation exit unavailable') await doc[method]() })) pendingExit = false const stillOwned = doc.pictureInPictureElement === video || doc.fullscreenElement === video failedExit = results.some(result => result.status === 'rejected') || stillOwned ? video : null exitVideo = !disposed && stillOwned ? video : null if (!disposed) { button.disabled = busy say(stillOwned ? 'Could not exit picture-in-picture. Use the browser controls or click to retry exiting. Your call remains connected.' : 'Return to the call. Choose a video to open picture-in-picture.') } } function stop() { const video = selected || exitVideo const owner = { session, acquisition } const release = Boolean(selected || reserved || reserving) reserved = false; reserving = false; acquisition = null // Detach before invoking browser APIs, which can emit departure inline. clear() void exitOwned(video).then(() => { if (release && !pendingEntry && !pendingExit && (!video || (failedExit !== video && doc.fullscreenElement !== video && doc.pictureInPictureElement !== video))) { send('release', { presentationSettled: true }, owner) } }) if (!disposed && !pendingExit) say('Return to the call. Choose a video to open picture-in-picture.') } function message(event) { if (event.source !== win.parent || event.origin !== origin) return const data = event.data if (data?.type !== 'archipelago:video-state' || data.version !== 1) return if (data.state === 'connected' && typeof data.session === 'string' && /^[a-f0-9-]{36}$/.test(data.session)) { if (session && session !== data.session) stop() session = data.session; native = data.native === true; return } if (!session || data.session !== session) return if (data.acquisition !== acquisition) return if (data.state === 'reserved' && reserving) { reserving = false; reserved = true; finish() say('Ready. Click picture-in-picture again to fullscreen the video.') return } if (!selected && !reserved && !reserving) return if (data.state === 'entered') { finish(); say('Picture-in-picture is active. Your call remains connected.') } if (data.state === 'error') { stop(); say(typeof data.message === 'string' ? data.message.slice(0, 300) : 'Picture-in-picture failed.') } if (data.state === 'closed' || data.state === 'restored' || data.state === 'released') { stop() } if (data.state === 'command' && selected) { if (data.command === 'pause') selected.pause() if (data.command === 'play') void selected.play().catch(() => say('Return to the call to resume video.')) } } async function enter() { if (disposed || busy || pendingEntry || pendingExit) return if (exitVideo) { if (doc.pictureInPictureElement === exitVideo || doc.fullscreenElement === exitVideo) { void exitOwned(exitVideo); return } exitVideo = null } if (selected) { stop(); return } const video = getVideo() if (!video || !video.videoWidth || !video.videoHeight || video.ended) { say('Wait for a call video before opening picture-in-picture.'); return } if (native && session && !(doc.pictureInPictureEnabled && typeof video.requestPictureInPicture === 'function') && !reserved) { acquisition = crypto.randomUUID() reserving = true; busy = true; button.disabled = true send('reserve') timer = setTimeout(() => { stop(); say('Picture-in-picture reservation was not confirmed.') }, 12000) return } if (!reserved) stop() selected = video for (const name of ['play', 'pause']) video.addEventListener(name, publish) const onTerminal = () => { if (terminalListener === onTerminal) stop() } terminalListener = onTerminal for (const name of ['ended', 'error']) video.addEventListener(name, onTerminal) let presented = false busy = true; button.disabled = true // Browser entry promises are not cancellable. Keep retries single-flight // until a cancelled request settles and its late-owned surface is released. pendingEntry = true const owner = { session, acquisition } try { if (doc.pictureInPictureEnabled && typeof video.requestPictureInPicture === 'function') { const onLeave = () => { if (leaveListener !== onLeave) return if (doc.pictureInPictureElement === video) presented = true else if (presented) stop() } leaveListener = onLeave video.addEventListener('enterpictureinpicture', onLeave) video.addEventListener('leavepictureinpicture', onLeave) await video.requestPictureInPicture() if (selected !== video) { await exitOwned(video) return } if (doc.pictureInPictureElement !== video) { stop(); return } presented = true say('Picture-in-picture is active. Your call remains connected.') finish() } else if (native && session && typeof video.requestFullscreen === 'function') { const onFullscreen = () => { if (fullscreenListener !== onFullscreen) return if (doc.fullscreenElement === video) presented = true else if (presented) stop() } fullscreenListener = onFullscreen doc.addEventListener('fullscreenchange', onFullscreen) // A second click supplies a fresh gesture after the host reservation. await video.requestFullscreen() if (selected !== video) { await exitOwned(video) return } if (doc.fullscreenElement !== video) { stop(); return } presented = true send('enter', { width: video.videoWidth, height: video.videoHeight, playing: !video.paused }) timer = setTimeout(() => { stop(); say('Picture-in-picture was not confirmed. Return to the call and retry.') }, 12000) } else { stop(); say('Picture-in-picture is unavailable in this browser or Companion. The call remains here.') } } catch (error) { if (selected === video) { stop(); say(error instanceof Error ? error.message : 'Picture-in-picture could not start.') } } finally { pendingEntry = false if (selected !== video && !pendingExit && failedExit !== video && doc.fullscreenElement !== video && doc.pictureInPictureElement !== video) { send('release', { presentationSettled: true }, owner) } } } const ready = () => { if (win.parent !== win) win.parent.postMessage({ type: 'archipelago:video-ready', version: 1 }, origin) } win.addEventListener('message', message) win.addEventListener('pageshow', ready) win.addEventListener('pagehide', stop) button.addEventListener('click', enter) ready() return { enter, stop, dispose() { disposed = true stop(); win.removeEventListener('message', message); win.removeEventListener('pageshow', ready) win.removeEventListener('pagehide', stop); button.removeEventListener('click', enter) } } }