feat(appsession): mode switches never reload the iframe + strictly per-app display modes + TV focus
- The session subtree now ALWAYS lives under <body>; inline panel mode is emulated with a fixed-position rect synced from the layout placeholder (ResizeObserver + resize), so toggling panel/overlay/fullscreen no longer re-parents — and therefore no longer reloads — the app iframe. - Display modes are strictly per-app: saved per-app pick → app default (IndeeHub: fullscreen) → panel. The global fallback is gone, so one app's mode can never change how another opens. - The iframe takes focus when the app loads, so keyboard / the gamepad bridge work inside it immediately (TV). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
80875a2ed4
commit
402183c0ae
@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<div class="app-session-root">
|
||||
<Teleport to="body" :disabled="inlinePanelMode">
|
||||
<!-- The root stays in the layout as a rect placeholder; the session itself
|
||||
ALWAYS lives under <body>. Toggling Teleport's disabled re-parented the
|
||||
subtree on panel<->overlay switches, and moving an iframe node reloads
|
||||
it — inline mode is now emulated with a fixed-position rect synced from
|
||||
this placeholder, so the iframe never moves in the DOM. -->
|
||||
<div class="app-session-root" ref="rootRef">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
:class="backdropClasses"
|
||||
:style="inlineRectStyle"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<div
|
||||
@ -228,8 +234,8 @@ function setMode(mode: DisplayMode) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
displayMode.value = mode
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
||||
// Remember the explicit pick for this app so it wins over the app default.
|
||||
// Strictly per-app: the pick is remembered for THIS app only (no global
|
||||
// key — one app's mode must never change how another opens).
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
|
||||
|
||||
// Route-based sessions (deep links) hand off to the store-driven session so
|
||||
@ -251,12 +257,52 @@ function setMode(mode: DisplayMode) {
|
||||
|
||||
// Reactive classes based on display mode. The store-driven session honors the
|
||||
// selected display mode in place: panel renders inline beside the page,
|
||||
// overlay/fullscreen render above it (teleported to body) — the underlying
|
||||
// route never changes. Mobile always uses the full overlay.
|
||||
// overlay/fullscreen render above it — the underlying route never changes.
|
||||
// Mobile always uses the full overlay.
|
||||
const inlinePanelMode = computed(() =>
|
||||
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
|
||||
)
|
||||
|
||||
// Inline-mode rect emulation: the always-body-teleported backdrop pins itself
|
||||
// to the placeholder's box so "inline" looks identical to the old in-place
|
||||
// render while the iframe stays put in the DOM across mode switches.
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const inlineRect = ref<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
let rectObserver: ResizeObserver | null = null
|
||||
|
||||
function syncInlineRect() {
|
||||
const el = rootRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
// A hidden/unmounted placeholder measures 0x0 — keep the last good rect.
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
inlineRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
||||
}
|
||||
}
|
||||
|
||||
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
|
||||
if (!inlinePanelMode.value) return undefined
|
||||
const r = inlineRect.value
|
||||
// Never paint the inline backdrop over the whole viewport while unmeasured.
|
||||
if (!r) {
|
||||
const hidden: Record<string, string> = { visibility: 'hidden' }
|
||||
return hidden
|
||||
}
|
||||
const style: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
top: `${r.top}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
zIndex: '100',
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
watch(inlinePanelMode, (on) => {
|
||||
if (on) void nextTick(syncInlineRect)
|
||||
})
|
||||
|
||||
const backdropClasses = computed(() => {
|
||||
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
|
||||
return 'app-session-backdrop-overlay'
|
||||
@ -277,6 +323,9 @@ function onLoad() {
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
autoRetryCount.value = 0
|
||||
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
|
||||
// virtual keyboard) flow into the iframe without needing a pointer click.
|
||||
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
|
||||
// Check if iframe actually loaded content (same-origin only)
|
||||
iframeCheckId = setTimeout(() => {
|
||||
try {
|
||||
@ -366,7 +415,6 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
function onFullscreenChange() {
|
||||
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
||||
displayMode.value = 'overlay'
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
|
||||
}
|
||||
}
|
||||
@ -406,7 +454,16 @@ onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
window.addEventListener('resize', updateIsMobile)
|
||||
window.addEventListener('resize', syncInlineRect)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
// Track the placeholder's box (sidebar collapse, layout shifts) for the
|
||||
// inline-mode fixed-position emulation. Sync before first paint so the
|
||||
// inline backdrop never flashes at the wrong rect.
|
||||
syncInlineRect()
|
||||
if (rootRef.value && typeof ResizeObserver !== 'undefined') {
|
||||
rectObserver = new ResizeObserver(syncInlineRect)
|
||||
rectObserver.observe(rootRef.value)
|
||||
}
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
@ -430,7 +487,10 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('resize', updateIsMobile)
|
||||
window.removeEventListener('resize', syncInlineRect)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
rectObserver?.disconnect()
|
||||
rectObserver = null
|
||||
screensaverStore.resume(screensaverReason.value)
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
})
|
||||
|
||||
@ -9,19 +9,18 @@ export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
/** Per-app default display mode. Used when the user hasn't explicitly picked
|
||||
* a mode for that app (an explicit pick is remembered per app and wins).
|
||||
* Apps not listed fall back to the last globally-used mode, then 'panel'. */
|
||||
* Apps not listed default to 'panel'. */
|
||||
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
|
||||
'indeedhub': 'fullscreen',
|
||||
}
|
||||
|
||||
/** Initial display mode for an app session: per-app user choice → per-app
|
||||
* default → last global choice → panel. */
|
||||
* default → panel. Strictly per-app — deliberately NO global fallback, so
|
||||
* one app's mode change can never affect how another app opens. */
|
||||
export function initialDisplayMode(id: string): DisplayMode {
|
||||
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
|
||||
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
|
||||
const appDefault = APP_DEFAULT_DISPLAY_MODE[id]
|
||||
if (appDefault) return appDefault
|
||||
return (localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
|
||||
return APP_DEFAULT_DISPLAY_MODE[id] ?? 'panel'
|
||||
}
|
||||
|
||||
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user