Draft retained Cloud video picture-in-picture for companion
This commit is contained in:
@@ -37,11 +37,13 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:supportsPictureInPicture="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
android:resizeableActivity="true"
|
||||||
android:theme="@style/Theme.Archipelago.Splash"
|
android:theme="@style/Theme.Archipelago.Splash"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import com.archipelago.app.ui.theme.ArchipelagoTheme
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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
|
// Pairing deep link (archipelago://pair?...) from the launch intent or a
|
||||||
// later one (launchMode=singleTask). Consumed by AppNavHost.
|
// 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 overlay: FrameLayout? = null
|
||||||
private var callback: WebChromeClient.CustomViewCallback? = null
|
private var callback: WebChromeClient.CustomViewCallback? = null
|
||||||
private var back: OnBackPressedCallback? = 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 visibleBars = 0
|
||||||
private var originalBehavior = 0
|
private var originalBehavior = 0
|
||||||
|
|
||||||
@@ -71,6 +73,22 @@ internal class WebViewFullscreen(private val activity: ComponentActivity?) {
|
|||||||
|
|
||||||
fun hide() {
|
fun hide() {
|
||||||
val host = overlay ?: return
|
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.
|
// Clear first: Chromium may synchronously call onHideCustomView again.
|
||||||
overlay = null
|
overlay = null
|
||||||
val notify = callback
|
val notify = callback
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ fun WebViewScreen(
|
|||||||
meshFallbackUrl: String? = null,
|
meshFallbackUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
val fullscreen = rememberWebViewFullscreen()
|
val fullscreen = rememberWebViewFullscreen()
|
||||||
|
val cloudPip = rememberCloudVideoPip(fullscreen)
|
||||||
val downloads = rememberWebViewDownloads()
|
val downloads = rememberWebViewDownloads()
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||||
@@ -915,6 +916,7 @@ fun WebViewScreen(
|
|||||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
|
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||||
setDownloadListener(downloads)
|
setDownloadListener(downloads)
|
||||||
settings.apply {
|
settings.apply {
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
@@ -1089,6 +1091,7 @@ fun WebViewScreen(
|
|||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
cloudPip.reset()
|
||||||
isLoading = true
|
isLoading = true
|
||||||
hasError = false
|
hasError = false
|
||||||
// New document — the injected safe-area style is
|
// New document — the injected safe-area style is
|
||||||
@@ -1188,7 +1191,7 @@ fun WebViewScreen(
|
|||||||
fullscreen.show(view, callback)
|
fullscreen.show(view, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onHideCustomView() = fullscreen.hide()
|
override fun onHideCustomView() { cloudPip.reset(); fullscreen.hide() }
|
||||||
|
|
||||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
loadProgress = newProgress
|
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)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(manifest = Config.NONE, sdk = [28, 35])
|
@Config(manifest = Config.NONE, sdk = [28, 35])
|
||||||
class WebViewFullscreenTest {
|
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() {
|
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
|
||||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,16 +31,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
|
v-if="(pipAvailable || companionPip.available) && currentItem && isVideoFile(currentItem)"
|
||||||
class="lightbox-btn"
|
class="lightbox-btn"
|
||||||
title="Picture-in-picture" aria-label="Picture-in-picture"
|
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">
|
<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="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" />
|
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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 { getFileCategory } from '@/composables/useFileType'
|
||||||
import { isPipSupported, togglePip } from '@/utils/pip'
|
import { isPipSupported, togglePip } from '@/utils/pip'
|
||||||
import FileActionsMenu from './FileActionsMenu.vue'
|
import FileActionsMenu from './FileActionsMenu.vue'
|
||||||
|
import { useCompanionVideoPip } from '@/composables/useCompanionVideoPip'
|
||||||
import { usePipSession } from '@/composables/usePipSession'
|
import { usePipSession } from '@/composables/usePipSession'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -167,6 +170,7 @@ const mediaError = ref(false)
|
|||||||
const currentUrl = ref<string | null>(null)
|
const currentUrl = ref<string | null>(null)
|
||||||
const backdropEl = ref<HTMLElement | null>(null)
|
const backdropEl = ref<HTMLElement | null>(null)
|
||||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||||
|
const companionPip = useCompanionVideoPip(videoEl)
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
let loadId = 0
|
let loadId = 0
|
||||||
@@ -257,6 +261,7 @@ function next() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
companionPip.stop()
|
||||||
if (document.fullscreenElement === backdropEl.value) void document.exitFullscreen?.().catch(() => {})
|
if (document.fullscreenElement === backdropEl.value) void document.exitFullscreen?.().catch(() => {})
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -359,12 +364,14 @@ watch(() => props.show, async (visible) => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
backdropEl.value?.focus()
|
backdropEl.value?.focus()
|
||||||
} else {
|
} else {
|
||||||
|
companionPip.stop()
|
||||||
++loadId
|
++loadId
|
||||||
restorePage()
|
restorePage()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch([currentItem, () => props.show], ([item, visible]) => {
|
watch([currentItem, () => props.show], ([item, visible]) => {
|
||||||
|
companionPip.stop()
|
||||||
if (visible && item) void loadMedia(item)
|
if (visible && item) void loadMedia(item)
|
||||||
}, { immediate: true })
|
}, { 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}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user