fix(android): clear the system bars and display cutout (UR-066)

The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.

None of the app's safe-area handling was ever active, for two independent
reasons:

  1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
     resolved to 0px — the padding in app.css and BottomUi was a no-op.
  2. Android WebView maps only the *display cutout* into `env()`; the status bar
     and navigation bar are never reported. With enableEdgeToEdge() and
     targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
     spans them, so CSS could not learn about them by any route.

WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.

Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.

The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.

Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
This commit is contained in:
2026-08-04 14:45:10 +02:00
parent 58f2506966
commit c55ff45692
20 changed files with 790 additions and 33 deletions
+35 -2
View File
@@ -24,15 +24,20 @@
showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
showGlobalHeader as computeShowGlobalHeader,
routeOwnsLayout as computeRouteOwnsLayout,
shellReservesBottomInset,
} from "$lib/utils/layoutShell";
import { registerNavigationTracking } from "$lib/utils/navigation";
import { startNetworkReporting } from "$lib/services/networkType";
import { initSafeArea } from "$lib/utils/safeArea";
let { children } = $props();
/** Teardown for the network-transport reporter (WiFi-only gate). */
let stopNetworkReporting: (() => void) | null = null;
/** Teardown for the native window-inset subscription (safe areas). */
let stopSafeArea: (() => void) | null = null;
// Track in-app navigation depth so the header "back" affordance knows when a
// real in-app Back exists (vs. a stale WebView stack after a background /
// restore). Must run during component init — afterNavigate needs a component
@@ -67,6 +72,14 @@
// scroller, with the root's in-flow BottomUi as a flex sibling below it.
const routeOwnsLayout = $derived(computeRouteOwnsLayout({ pathname }));
// Bottom safe-area inset (Android navigation/gesture bar): BottomUi reserves
// it wherever one renders, so the shell only takes it on routes that have no
// bottom UI at all (login, the full-screen player). Exactly one owner, or the
// bar is either ignored or double-padded. (UR-066)
const shellPadsBottom = $derived(
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated })
);
onMount(async () => {
// Detect platform first (synchronously, before any await) so the global
// mini player's Android visibility gate is correct from the first render.
@@ -80,6 +93,13 @@
console.error("Platform detection failed:", err);
}
// Prime the safe-area custom properties from the native WindowInsets bridge
// BEFORE the first await, so the very first paint already clears the status
// bar and the navigation/gesture bar. Native also pushes updates directly,
// but a page load wipes the inline style it set, so this pull is required.
// No-op without the Android bridge. (UR-066)
stopSafeArea = initSafeArea();
// Initialize auth state (restore session from secure storage)
await auth.initialize();
isInitialized.set(true);
@@ -127,6 +147,7 @@
onDestroy(() => {
stopNetworkReporting?.();
stopSafeArea?.();
cleanupPlayerEvents();
cleanupWebviewAudio();
cleanupDownloadEvents();
@@ -177,7 +198,19 @@
});
</script>
<div class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col">
<!--
The app shell reserves the top/side safe-area insets (status bar, display
cutout) so no route has to. The bottom inset belongs to BottomUi wherever one
renders — see shellReservesBottomInset. `h-screen` is border-box, so the
padding is taken out of the 100vh rather than added to it.
TRACES: UR-066 | DR-112
-->
<div
class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col
pt-[var(--safe-top)] pl-[var(--safe-left)] pr-[var(--safe-right)]"
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
>
{#if isInitialized}
<!-- Offline indicator banner -->
{#if $isAuthenticated && !$isConnected}
@@ -249,7 +282,7 @@
onClose={() => showSleepTimerModal.set(false)}
/>
{:else}
<div class="flex items-center justify-center h-screen">
<div class="flex items-center justify-center h-full">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{/if}
+2 -2
View File
@@ -138,11 +138,11 @@
</script>
{#if isLoading}
<div class="h-screen flex justify-center items-center">
<div class="h-full flex justify-center items-center">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="h-screen overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
<div class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
<div class="space-y-8">
<!-- Hero Banner -->
+2 -2
View File
@@ -73,11 +73,11 @@
</script>
{#if $isAuthLoading}
<div class="min-h-screen flex items-center justify-center">
<div class="min-h-full flex items-center justify-center">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if $isAuthenticated}
<div class="h-screen flex flex-col overflow-hidden">
<div class="h-full flex flex-col overflow-hidden">
<!-- Header (shared across all authenticated chrome; library supplies search) -->
<AppHeader search={librarySearch} />
+1 -1
View File
@@ -68,7 +68,7 @@
}
</script>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="min-h-full flex items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Logo/Title -->
<div class="text-center mb-8">
+1 -1
View File
@@ -13,7 +13,7 @@
}
</script>
<div class="min-h-screen bg-[var(--color-background)] p-4 md:p-8">
<div class="min-h-full bg-[var(--color-background)] p-4 md:p-8">
<div class="max-w-7xl mx-auto">
<!-- Page Header -->
<header class="mb-8">