192 lines
9.4 KiB
JavaScript
192 lines
9.4 KiB
JavaScript
/** 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)
|
|
} }
|
|
}
|