Files
jellytau/src/lib/utils/safeArea.ts
T
dtourolle d54d8cc7c4 refactor(logging): route frontend console calls through the logger
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.
2026-08-20 19:29:59 +02:00

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);
}