Draft retained Cloud video picture-in-picture for companion
This commit is contained in:
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user