TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
172 lines
5.7 KiB
TypeScript
172 lines
5.7 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("SafeArea");
|
|
|
|
/** 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<string, unknown>;
|
|
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<string, string> {
|
|
return Object.fromEntries(EDGES.map((e) => [`--jt-inset-${e}`, `${insets[e]}px`]));
|
|
}
|
|
|
|
/** Write the inset custom properties onto an element (normally `<html>`). */
|
|
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) {
|
|
log.warn("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);
|
|
}
|