/** * Safe-area (window inset) plumbing. * * TRACES: UR-066 | IR-031, DR-112 * * ## Why this exists * * `MainActivity` calls `enableEdgeToEdge()`, and the app targets SDK 36 — from * SDK 35 edge-to-edge is mandatory and from SDK 36 the opt-out is ignored — so * the Tauri WebView always spans the **entire window**, underneath the status * bar, the navigation/gesture bar and the display cutout. * * CSS cannot discover that on its own: * * - `env(safe-area-inset-*)` resolves to `0px` unless the viewport declares * `viewport-fit=cover` (see `src/app.html`), and * - even then, Android WebView only maps the **display cutout** into * `env(safe-area-inset-*)`. The status bar and the navigation bar are never * reported. Unlike iOS Safari, there is no CSS-visible system-bar inset. * * So native reads the real `WindowInsets` (`systemBars() | displayCutout()`) * and pushes them in as CSS custom properties; `src/app.css` folds them * together with `env()` via `max()` so iOS/desktop keep working unchanged: * * ```css * --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--jt-inset-bottom, 0px)); * ``` * * Two delivery paths, because either alone is insufficient: * * - **push** — `WindowInsetsBridge` evaluates JS into the WebView on every * inset change (rotation, nav-mode switch, PiP enter/exit). Needed because * insets change after load. * - **pull** — `initSafeArea()` reads `window.AndroidInsets.get()` at startup. * Needed because the first inset pass usually lands *before* the SvelteKit * document exists, and a page load wipes any inline style native had set. */ /** Window insets in CSS pixels, one per edge. */ export interface SafeAreaInsets { top: number; right: number; bottom: number; left: number; } /** No insets — the desktop/dev default. */ export const ZERO_INSETS: SafeAreaInsets = { top: 0, right: 0, bottom: 0, left: 0 }; /** DOM event native fires after pushing a new set of insets. */ export const INSETS_CHANGED_EVENT = "jellytau-insets-changed"; const EDGES = ["top", "right", "bottom", "left"] as const; /** The native @JavascriptInterface installed by MainActivity (Android only). */ interface AndroidInsetsBridge { /** JSON `{top,right,bottom,left}` in CSS pixels. */ get(): string; } declare global { interface Window { AndroidInsets?: AndroidInsetsBridge; } } /** * Coerce one edge to a non-negative finite number. * * Anything else becomes 0 rather than propagating: a `NaN` would serialise to * `"NaNpx"`, which invalidates the whole declaration and silently restores the * original bug. */ function edge(value: unknown): number { const n = typeof value === "string" ? Number(value) : value; if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return 0; return n; } /** * Parse a native inset payload (JSON string or already-decoded object). * Returns `null` when the input is not an inset payload at all, so callers can * distinguish "no insets reported" from "insets are all zero". */ export function parseNativeInsets(raw: unknown): SafeAreaInsets | null { let value = raw; if (typeof value === "string") { try { value = JSON.parse(value); } catch { return null; } } if (typeof value !== "object" || value === null || Array.isArray(value)) return null; const record = value as Record; if (!EDGES.some((e) => e in record)) return null; return { top: edge(record.top), right: edge(record.right), bottom: edge(record.bottom), left: edge(record.left), }; } /** The CSS custom properties for a set of insets. */ export function safeAreaCssVars(insets: SafeAreaInsets): Record { return Object.fromEntries(EDGES.map((e) => [`--jt-inset-${e}`, `${insets[e]}px`])); } /** Write the inset custom properties onto an element (normally ``). */ export function applySafeAreaInsets(target: HTMLElement, insets: SafeAreaInsets): void { for (const [name, value] of Object.entries(safeAreaCssVars(insets))) { target.style.setProperty(name, value); } } /** * Read the current insets from the native bridge, or `null` when there is none. * * Never throws. WebView can hand JS a *stale proxy* after a page load — the * injected object stays truthy while its methods vanish (the exact failure that * broke the background-audio toggle, see `MainActivity.configureWebViewForMedia`). * Layout init must survive that. */ export function readNativeInsets(): SafeAreaInsets | null { if (typeof window === "undefined") return null; const bridge = window.AndroidInsets; if (!bridge || typeof bridge.get !== "function") return null; try { return parseNativeInsets(bridge.get()); } catch (err) { console.warn("[SafeArea] AndroidInsets bridge unusable:", err); return null; } } /** * Prime the safe-area custom properties and keep them current. * * Call once, early in the root layout's `onMount` (synchronously — before any * `await`, so the very first paint is already inset-correct). Returns a * teardown that unsubscribes. * * With no native bridge this is a near no-op: it deliberately does NOT write * `0px`, so the `env(safe-area-inset-*)` half of the `max()` still wins on iOS * and desktop. */ export function initSafeArea(target?: HTMLElement): () => void { if (typeof window === "undefined") return () => {}; const el = target ?? document.documentElement; const sync = () => { const insets = readNativeInsets(); if (insets) applySafeAreaInsets(el, insets); }; sync(); window.addEventListener(INSETS_CHANGED_EVENT, sync); return () => window.removeEventListener(INSETS_CHANGED_EVENT, sync); }