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
+3 -1
View File
@@ -37,11 +37,13 @@
<activity
android:name=".MainActivity"
android:supportsPictureInPicture="true"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -14,6 +14,13 @@ import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() {
internal var cloudVideoPip: com.archipelago.app.ui.screens.CloudVideoPip? = null
override fun onPictureInPictureModeChanged(active: Boolean, config: android.content.res.Configuration) {
super.onPictureInPictureModeChanged(active, config)
cloudVideoPip?.modeChanged(active)
}
override fun onStop() { cloudVideoPip?.stopped(); super.onStop() }
// Pairing deep link (archipelago://pair?...) from the launch intent or a
// later one (launchMode=singleTask). Consumed by AppNavHost.
@@ -0,0 +1,193 @@
package com.archipelago.app.ui.screens
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.net.Uri
import android.util.Rational
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.archipelago.app.MainActivity
import org.json.JSONObject
import java.net.URI
import java.util.UUID
internal fun cloudVideoOrigin(value: String?): String? = runCatching {
val uri = URI(value ?: return null)
val scheme = uri.scheme?.lowercase() ?: return null
if (scheme !in setOf("http", "https") || uri.userInfo != null) return null
val host = uri.host?.lowercase() ?: return null
val port = uri.port.takeUnless { it == -1 || it == if (scheme == "https") 443 else 80 }
"$scheme://$host${port?.let { ":$it" } ?: ""}"
}.getOrNull()
internal fun cloudVideoSenderAllowed(currentUrl: String?, source: String, allowed: Set<String>, mainFrame: Boolean): Boolean {
val origin = cloudVideoOrigin(source)
return mainFrame && origin != null && origin in allowed && cloudVideoOrigin(currentUrl) == origin
}
/** Only the dashboard's origin-restricted main-frame channel can arm Cloud PiP.
* No URL, cookies, bearer token or second media player enters native storage. */
internal class CloudVideoPip(private val activity: MainActivity?, private val fullscreen: WebViewFullscreen) {
private class Binding(val origins: Set<String>, var owner: java.lang.ref.WeakReference<CloudVideoPip>)
companion object {
private val bindings = java.util.WeakHashMap<WebView, Binding>()
}
private var webView: WebView? = null
private var session: String? = null
private var reply: JavaScriptReplyProxy? = null
private var playing = false
private var ratio = Rational(16, 9)
private var entered = false
private var registered = false
private val action = "com.archipelago.app.CLOUD_VIDEO_PIP.${UUID.randomUUID()}"
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (!entered || intent?.action != action || intent.getStringExtra("session") != session) return
event("command", if (playing) "pause" else "play")
}
}
private fun supported() = activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) == true
private fun event(state: String, command: String? = null) {
val message = JSONObject().put("type", "event").put("session", session).put("state", state)
if (command != null) message.put("command", command)
runCatching { reply?.postMessage(message.toString()) }
}
private fun params(): PictureInPictureParams {
val owner = requireNotNull(activity)
val intent = Intent(action).setPackage(owner.packageName).putExtra("session", session)
val pending = PendingIntent.getBroadcast(owner, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val control = RemoteAction(Icon.createWithResource(owner, if (playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play),
if (playing) "Pause" else "Play", if (playing) "Pause video" else "Play video", pending)
val bounds = Rect()
val builder = PictureInPictureParams.Builder().setAspectRatio(ratio).setActions(listOf(control))
if (fullscreen.bounds(bounds)) builder.setSourceRectHint(bounds)
return builder.build()
}
fun attach(view: WebView, allowedUrls: List<String>) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
val origins = allowedUrls.mapNotNull(::cloudVideoOrigin).toSet()
if (origins.isEmpty()) return
webView = view
val existing = bindings[view]
if (existing != null) {
// Rebind the retained document; removing/re-adding a listener would
// require a reload and strand the page's existing JS bridge.
if (existing.origins != origins) {
existing.owner.clear(); webView = null
return // The page receives a bounded unavailable response; reconnect reloads policy.
}
existing.owner = java.lang.ref.WeakReference(this)
return
}
val binding = Binding(origins, java.lang.ref.WeakReference(this))
bindings[view] = binding
WebViewCompat.addWebMessageListener(view, "ArchipelagoCloudVideo", origins,
object : WebViewCompat.WebMessageListener {
override fun onPostMessage(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy) {
binding.owner.get()?.receive(web, message, sourceOrigin, isMainFrame, proxy, binding.origins)
}
})
}
private fun receive(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy, origins: Set<String>) {
if (web !== webView || !cloudVideoSenderAllowed(web.url, sourceOrigin.toString(), origins, isMainFrame)) return
val raw = runCatching { message.data }.getOrNull() ?: return
if (raw.length > 2048) return
val request = runCatching { JSONObject(raw) }.getOrNull() ?: return
val id = request.optString("id")
if (!id.matches(Regex("[0-9a-f-]{36}"))) return
val response = JSONObject().put("id", id)
runCatching {
when (request.optString("action")) {
"capabilities" -> response.put("supported", supported()).put("version", 1)
"arm" -> {
check(supported()) { "Picture-in-picture is unavailable on this device." }
check(!entered) { "A video is already in picture-in-picture." }
val width = request.optInt("width", 0); val height = request.optInt("height", 0)
check(width in 1..16384 && height in 1..16384) { "Video dimensions are not ready." }
ratio = Rational(((width.toDouble() / height).coerceIn(1.0 / 2.39, 2.39) * 10000).toInt(), 10000)
session = id; reply = proxy; playing = request.optBoolean("playing", false)
response.put("session", id)
}
"enter" -> {
check(request.optString("session") == session && session != null && fullscreen.isActive) { "Open the selected Cloud video fullscreen first." }
val owner = requireNotNull(activity)
if (!registered) {
ContextCompat.registerReceiver(owner, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED)
registered = true
}
check(owner.enterPictureInPictureMode(params())) { "Picture-in-picture is disabled or unavailable. Check this app's system setting." }
entered = true
response.put("active", true)
}
"state" -> {
check(request.optString("session") == session && session != null) { "Video session changed." }
playing = request.optBoolean("playing", false)
if (entered) activity?.setPictureInPictureParams(params())
}
"release" -> {
check(request.optString("session") == session && session != null) { "Video session changed." }
reset()
}
else -> error("Unsupported Cloud video action.")
}
}.onFailure { response.put("error", it.message ?: "Picture-in-picture is unavailable.") }
proxy.postMessage(response.toString())
}
fun modeChanged(active: Boolean) {
if (active) { entered = true; event("entered") }
else if (entered) {
entered = false
event("restored")
// Restoring the viewer is not a stop request. Retire the native
// session before Chromium's hide callback can recursively reset it.
session = null; reply = null
fullscreen.hide()
}
}
fun stopped() { if (entered) { event("command", "pause"); event("closed") } }
fun reset() {
event("command", "pause")
event("closed")
session = null; reply = null
fullscreen.hide()
}
fun dispose() {
reset()
if (registered) runCatching { activity?.unregisterReceiver(receiver) }
registered = false
webView?.let { view -> bindings[view]?.takeIf { it.owner.get() === this }?.owner?.clear() }
webView = null
}
}
private fun Context.pipActivity(): MainActivity? = when(this) {
is MainActivity -> this
is ContextWrapper -> baseContext.takeIf { it !== this }?.pipActivity()
else -> null
}
@Composable
internal fun rememberCloudVideoPip(fullscreen: WebViewFullscreen): CloudVideoPip {
val owner = LocalContext.current.pipActivity()
val pip = remember(owner, fullscreen) { CloudVideoPip(owner, fullscreen) }
DisposableEffect(pip) {
owner?.cloudVideoPip = pip
onDispose { if (owner != null && owner.cloudVideoPip === pip) owner.cloudVideoPip = null; pip.dispose() }
}
return pip
}
@@ -29,6 +29,8 @@ internal class WebViewFullscreen(private val activity: ComponentActivity?) {
private var overlay: FrameLayout? = null
private var callback: WebChromeClient.CustomViewCallback? = null
private var back: OnBackPressedCallback? = null
val isActive: Boolean get() = overlay != null
fun bounds(rect: android.graphics.Rect): Boolean = overlay?.getGlobalVisibleRect(rect) == true
private var visibleBars = 0
private var originalBehavior = 0
@@ -71,6 +73,22 @@ internal class WebViewFullscreen(private val activity: ComponentActivity?) {
fun hide() {
val host = overlay ?: return
if (activity?.isInPictureInPictureMode == true) {
// A navigation/logout/custom-view exit must never expose the node
// management UI in the small OS window. Keep a black cover until
// the activity leaves PiP; the ordinary hide then removes it.
val notify = callback; callback = null
back?.remove(); back = null
host.keepScreenOn = false; host.removeAllViews()
host.addView(android.widget.TextView(host.context).apply {
text = "Video paused. Expand to return."
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
contentDescription = "Video paused. Use picture-in-picture controls to expand or close."
}, FrameLayout.LayoutParams(-1, -1))
notify?.onCustomViewHidden()
return
}
// Clear first: Chromium may synchronously call onHideCustomView again.
overlay = null
val notify = callback
@@ -612,6 +612,7 @@ fun WebViewScreen(
meshFallbackUrl: String? = null,
) {
val fullscreen = rememberWebViewFullscreen()
val cloudPip = rememberCloudVideoPip(fullscreen)
val downloads = rememberWebViewDownloads()
var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded
@@ -915,6 +916,7 @@ fun WebViewScreen(
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
setDownloadListener(downloads)
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
@@ -1089,6 +1091,7 @@ fun WebViewScreen(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
cloudPip.reset()
isLoading = true
hasError = false
// New document — the injected safe-area style is
@@ -1188,7 +1191,7 @@ fun WebViewScreen(
fullscreen.show(view, callback)
}
override fun onHideCustomView() = fullscreen.hide()
override fun onHideCustomView() { cloudPip.reset(); fullscreen.hide() }
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
@@ -0,0 +1,26 @@
package com.archipelago.app.ui.screens
import org.junit.Assert.*
import org.junit.Test
class CloudVideoPipTest {
@Test fun nativeChannelRejectsSiblingFrameAndStaleOrForeignPage() {
val allowed = setOf("https://node.test", "http://[fd00::1]")
assertTrue(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, false))
assertFalse(cloudVideoSenderAllowed("https://other.test", "https://node.test", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test:7778", "https://node.test:7778", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test", "http://node.test", allowed, true))
}
@Test fun exactOriginIncludesSchemeAndNonDefaultPort() {
assertEquals("https://node.test", cloudVideoOrigin("https://NODE.test:443/cloud"))
assertEquals("http://node.test:8080", cloudVideoOrigin("http://node.test:8080/cloud?file=video"))
assertEquals("http://[fd00::1]", cloudVideoOrigin("http://[fd00::1]/cloud"))
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("http://node.test"))
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("https://node.test:7778"))
}
@Test fun unsupportedAndCredentialOriginsCannotReceiveBridge() {
for (url in listOf("javascript:alert(1)", "file:///video", "data:text/plain,video", "https://user:password@node.test/video", "not-a-url")) assertNull(cloudVideoOrigin(url))
}
}
@@ -13,6 +13,25 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28, 35])
class WebViewFullscreenTest {
@Test fun closingVideoInPipKeepsOpaqueCoverUntilActivityReturns() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val video = View(activity)
var hidden = 0
fullscreen.show(video) { hidden++ }
assertTrue(activity.enterPictureInPictureMode(android.app.PictureInPictureParams.Builder().build()))
assertTrue(activity.isInPictureInPictureMode)
fullscreen.hide()
assertNull(video.parent)
assertTrue(fullscreen.isActive)
assertEquals(1, hidden)
fullscreen.hide()
assertEquals(1, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
@@ -31,16 +31,18 @@
</svg>
</button>
<button
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
v-if="(pipAvailable || companionPip.available) && currentItem && isVideoFile(currentItem)"
class="lightbox-btn"
title="Picture-in-picture" aria-label="Picture-in-picture"
@click.stop="togglePip(videoEl)"
@click.stop="companionPip.available ? companionPip.enter() : togglePip(videoEl)"
:disabled="companionPip.busy.value"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<span v-if="companionPip.error.value" role="alert" class="max-w-64 text-xs text-red-200">{{ companionPip.error.value }}</span>
<button class="lightbox-btn" aria-label="Close viewer" title="Close" @click="close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -143,6 +145,7 @@ import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
import { isPipSupported, togglePip } from '@/utils/pip'
import FileActionsMenu from './FileActionsMenu.vue'
import { useCompanionVideoPip } from '@/composables/useCompanionVideoPip'
import { usePipSession } from '@/composables/usePipSession'
const props = defineProps<{
@@ -167,6 +170,7 @@ const mediaError = ref(false)
const currentUrl = ref<string | null>(null)
const backdropEl = ref<HTMLElement | null>(null)
const videoEl = ref<HTMLVideoElement | null>(null)
const companionPip = useCompanionVideoPip(videoEl)
const expanded = ref(false)
let loadId = 0
@@ -257,6 +261,7 @@ function next() {
}
function close() {
companionPip.stop()
if (document.fullscreenElement === backdropEl.value) void document.exitFullscreen?.().catch(() => {})
emit('close')
}
@@ -359,12 +364,14 @@ watch(() => props.show, async (visible) => {
await nextTick()
backdropEl.value?.focus()
} else {
companionPip.stop()
++loadId
restorePage()
}
}, { immediate: true })
watch([currentItem, () => props.show], ([item, visible]) => {
companionPip.stop()
if (visible && item) void loadMedia(item)
}, { immediate: true })
@@ -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}
}