Files
archy/docker/kithmoot/archipelago-video.mjs

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)
} }
}