Draft retained Cloud video picture-in-picture for companion

This commit is contained in:
archipelago
2026-10-07 11:31:10 -04:00
parent 4d938cd5aa
commit 0d8decb366
10 changed files with 418 additions and 4 deletions
@@ -0,0 +1,43 @@
import {mount} from '@vue/test-utils'
import {defineComponent,shallowRef} from 'vue'
import {afterEach,describe,expect,it,vi} from 'vitest'
import {useCompanionVideoPip} from '../useCompanionVideoPip'
afterEach(()=>{delete (window as any).ArchipelagoCloudVideo;delete (window as any).ArchipelagoNative})
function fixture() {
const messages:any[]=[]
const bridge:any={onmessage:null,postMessage:vi.fn((raw:string)=>{
const message=JSON.parse(raw);messages.push(message)
queueMicrotask(()=>bridge.onmessage?.({data:JSON.stringify({id:message.id,...(message.action==='arm'?{session:message.id}:message.action==='enter'?{active:true}:{})})}))
})}
;(window as any).ArchipelagoCloudVideo=bridge
const video=document.createElement('video')
Object.defineProperties(video,{videoWidth:{value:1920},videoHeight:{value:1080},requestFullscreen:{value:vi.fn(async()=>{})}})
video.pause=vi.fn();video.play=vi.fn(async()=>{})
let pip!:ReturnType<typeof useCompanionVideoPip>
const wrapper=mount(defineComponent({setup(){pip=useCompanionVideoPip(shallowRef(video));return()=>null}}))
return {bridge,messages,video,pip,wrapper}
}
describe('retained companion Cloud video PiP',()=>{
it('enters only after actual video fullscreen and sends no stream or credentials',async()=>{
const f=fixture();await f.pip.enter()
expect(f.video.requestFullscreen).toHaveBeenCalledOnce();expect(f.messages.map(v=>v.action)).toEqual(['arm','enter'])
expect(f.messages[0]).toMatchObject({width:1920,height:1080})
expect(f.messages.every(v=>!('url' in v)&&!('token' in v)&&!('cookie' in v))).toBe(true)
expect(f.pip.active.value).toBe(true)
f.bridge.onmessage({data:JSON.stringify({type:'event',session:f.messages[0].id,state:'command',command:'pause'})})
expect(f.video.pause).toHaveBeenCalled()
f.bridge.onmessage({data:JSON.stringify({type:'event',session:f.messages[0].id,state:'restored'})})
expect(f.pip.active.value).toBe(false);expect(f.messages.at(-1).action).toBe('release');f.wrapper.unmount()
})
it('ignores another session controls and stops original video on native dismissal',async()=>{
const f=fixture();await f.pip.enter();vi.mocked(f.video.pause).mockClear()
f.bridge.onmessage({data:JSON.stringify({type:'event',session:'foreign',state:'command',command:'pause'})})
expect(f.video.pause).not.toHaveBeenCalled()
f.bridge.onmessage({data:JSON.stringify({type:'event',session:f.messages[0].id,state:'closed'})})
expect(f.video.pause).toHaveBeenCalledOnce();expect(f.pip.active.value).toBe(false);f.wrapper.unmount()
})
it('reports unavailable support without handing off or losing the video',async()=>{
const f=fixture();delete (window as any).ArchipelagoCloudVideo
await f.pip.enter();expect(f.pip.error.value).toContain('unavailable');expect(f.video.requestFullscreen).not.toHaveBeenCalled();f.wrapper.unmount()
})
})
@@ -0,0 +1,96 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
interface NativeVideoBridge { postMessage(message: string): void; onmessage: ((event: { data: string }) => void) | null }
type NativeWindow = Window & { ArchipelagoCloudVideo?: NativeVideoBridge; ArchipelagoNative?: unknown }
interface Channel { call(action: string, values?: Record<string, unknown>, id?: string): Promise<Record<string, unknown>>; subscribe(listener: (event: Record<string, unknown>) => void): () => void }
const channels = new WeakMap<NativeVideoBridge, Channel>()
function identifier() {
const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6]! & 15) | 64; bytes[8] = (bytes[8]! & 63) | 128
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`
}
function nativeChannel(): Channel | null {
const bridge = (window as NativeWindow).ArchipelagoCloudVideo
if (!bridge || typeof bridge.postMessage !== 'function') return null
const existing = channels.get(bridge); if (existing) return existing
const waiting = new Map<string, {resolve(value: Record<string, unknown>): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout>}>()
const listeners = new Set<(event: Record<string, unknown>) => void>()
bridge.onmessage = event => {
let data: Record<string, unknown>
try { data = JSON.parse(event.data) as Record<string, unknown> } catch { return }
if (!data || typeof data !== 'object') return
if (data.type === 'event') { for (const listener of listeners) listener(data); return }
if (typeof data.id !== 'string') return
const call = waiting.get(data.id); if (!call) return
waiting.delete(data.id); clearTimeout(call.timer)
if (typeof data.error === 'string') call.reject(new Error(data.error)); else call.resolve(data)
}
const channel: Channel = {
call(action, values = {}, id = identifier()) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { waiting.delete(id); reject(new Error('The companion did not confirm picture-in-picture. Return to the viewer and retry.')) }, 5000)
waiting.set(id, {resolve,reject,timer})
try { bridge.postMessage(JSON.stringify({id,action,...values})) }
catch { waiting.delete(id); clearTimeout(timer); reject(new Error('Picture-in-picture is unavailable in this companion.')) }
})
},
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) },
}
channels.set(bridge,channel); return channel
}
/** Keeps the original Cloud video and authenticated WebView. No stream address
* crosses the native bridge and no second player is created. */
export function useCompanionVideoPip(video: Ref<HTMLVideoElement | null>) {
const available = Boolean((window as NativeWindow).ArchipelagoCloudVideo || (window as NativeWindow).ArchipelagoNative)
const active = ref(false), error = ref(''), busy = ref(false)
let session: string | null = null, selected: HTMLVideoElement | null = null, generation = 0
let unsubscribe: (() => void) | null = null
const exitFullscreen = () => { if (document.fullscreenElement === selected) void document.exitFullscreen?.().catch(() => {}) }
function publish() { if (session && selected) void nativeChannel()?.call('state',{session,playing:!selected.paused && !selected.ended}).catch(() => {}) }
function clear() {
const original = session; session = null; active.value = false; busy.value = false; ++generation
exitFullscreen()
selected?.removeEventListener('play',publish); selected?.removeEventListener('pause',publish)
selected?.removeEventListener('ended',stop); selected?.removeEventListener('error',stop)
unsubscribe?.(); unsubscribe = null; selected = null
if (original) void nativeChannel()?.call('release',{session:original}).catch(() => {})
}
function stop() { selected?.pause(); clear() }
async function enter() {
if (busy.value || active.value) return
const channel = nativeChannel(), target = video.value
if (!channel || !target || typeof target.requestFullscreen !== 'function') { error.value = 'Picture-in-picture is unavailable in this companion or system WebView.'; return }
if (!target.videoWidth || !target.videoHeight) { error.value = 'Wait for the video to load before opening picture-in-picture.'; return }
clear(); const scope = generation
session = identifier(); selected = target; error.value = ''; busy.value = true
const original = session
unsubscribe = channel.subscribe(event => {
if (event.session !== session || selected !== target) return
if (event.state === 'entered') active.value = true
if (event.state === 'command') {
if (event.command === 'pause') target.pause()
else if (event.command === 'play') void target.play().catch(() => { error.value = 'Playback could not resume. Return to the viewer.' })
}
if (event.state === 'closed') stop()
if (event.state === 'restored') clear()
})
for (const name of ['play','pause']) target.addEventListener(name,publish)
target.addEventListener('ended',stop); target.addEventListener('error',stop)
try {
// Start fullscreen in the original user gesture; arm and fullscreen run
// together. Native entry additionally requires the real custom view.
const armed = channel.call('arm',{width:target.videoWidth,height:target.videoHeight,playing:!target.paused},original)
let fullscreen: Promise<void>
try { fullscreen = target.requestFullscreen() } catch (cause) { fullscreen = Promise.reject(cause) }
await Promise.all([armed,fullscreen])
if (generation !== scope || video.value !== target) return
const result = await channel.call('enter',{session:original})
if (generation !== scope) return
if (result.active !== true) throw new Error('The companion did not enter picture-in-picture.')
active.value = true
} catch (cause) {
if (generation === scope) { error.value = cause instanceof Error ? cause.message : 'Picture-in-picture is unavailable.'; clear() }
} finally { if (generation === scope) busy.value = false }
}
onBeforeUnmount(stop)
return {available,active,error,busy,enter,stop}
}