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:
@@ -75,6 +75,7 @@ For a narrative overview of the system design, see
|
||||
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
|
||||
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
|
||||
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
|
||||
| UR-066 | The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested on | High | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -115,6 +116,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
@@ -264,6 +266,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
|
||||
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
|
||||
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
|
||||
| DR-112 | Safe-area insets come from **native**, not from `env()` alone. `env(safe-area-inset-*)` is 0px without `viewport-fit=cover` (missing from `app.html`, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the *display cutout* — never the status bar or navigation bar. Since `enableEdgeToEdge()` plus `targetSdk 36` make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. `WindowInsetsBridge` reads the real insets and publishes `jt-inset` custom properties; `app.css` folds them with `env()` via `max()` into `--safe-*`, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (`shellReservesBottomInset` hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's `fitsSystemWindows=true` — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removed | UI | UR-066 | Done |
|
||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
|
||||
---
|
||||
@@ -337,6 +340,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||||
| UR-063 | - | DR-105 |
|
||||
| UR-064 | - | DR-106 |
|
||||
| UR-066 | IR-031 | DR-112 |
|
||||
|
||||
---
|
||||
|
||||
@@ -436,6 +440,11 @@ Internal architecture, components, and application logic.
|
||||
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
||||
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
|
||||
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
|
||||
| UT-094 | `parseNativeInsets` accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting `NaNpx` (which would invalidate the whole padding declaration) | DR-112 | Done |
|
||||
| UT-095 | `safeAreaCssVars`/`applySafeAreaInsets` emit px-suffixed `jt-inset` custom properties for all four edges | DR-112 | Done |
|
||||
| UT-096 | `readNativeInsets` returns null with no bridge and survives a stale WebView proxy (missing or throwing `get`) instead of throwing out of layout init | IR-031, DR-112 | Done |
|
||||
| UT-097 | `initSafeArea` primes the document on start, re-applies on `jellytau-insets-changed` (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so `env()` still wins on iOS/desktop | IR-031, DR-112 | Done |
|
||||
| UT-098 | `shellReservesBottomInset` gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded | DR-112 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(64);
|
||||
expect(defined.IR).toBe(29);
|
||||
expect(defined.DR).toBe(104);
|
||||
expect(defined.UR).toBe(65);
|
||||
expect(defined.IR).toBe(30);
|
||||
expect(defined.DR).toBe(105);
|
||||
expect(defined.JA).toBe(32);
|
||||
expect(defined.total).toBe(229);
|
||||
expect(defined.total).toBe(232);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# breaking the PiP button in release builds only.
|
||||
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
||||
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
||||
-keep class com.dtourolle.jellytau.WindowInsetsBridge { *; }
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ class MainActivity : TauriActivity() {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// enableEdgeToEdge() puts the WebView under the status bar, the navigation/
|
||||
// gesture bar and the display cutout — and targeting SDK 36 makes that
|
||||
// non-optional anyway. Android WebView never surfaces the *system bar*
|
||||
// insets to CSS (only the display cutout), so the web layer has to be told.
|
||||
// Without this the bottom nav renders underneath the navigation bar, badly
|
||||
// so on devices with a tall opaque 3-button bar. (UR-066)
|
||||
WindowInsetsBridge.install(this)
|
||||
|
||||
// Configure WebView for media playback after Tauri initialization
|
||||
handler.postDelayed({
|
||||
configureWebViewForMedia()
|
||||
@@ -179,6 +187,12 @@ class MainActivity : TauriActivity() {
|
||||
//
|
||||
// The settings/WebChromeClient work below is idempotent and must keep
|
||||
// running on resume; only the bridge injection is one-shot.
|
||||
|
||||
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
|
||||
// idempotent and MUST re-run: a page load discards the inline style the
|
||||
// last push set, so the WebView would otherwise be left with no insets.
|
||||
WindowInsetsBridge.attachWebView(webView)
|
||||
|
||||
if (webView === bridgesInstalledOn) {
|
||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||
configureWebViewSettings(webView)
|
||||
@@ -257,6 +271,11 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Window insets (safe areas). The push path above races the page load, so
|
||||
// the frontend pulls the current values on mount through this bridge.
|
||||
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidInsets' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
/**
|
||||
* Publishes the Activity's real window insets to the WebView as CSS custom
|
||||
* properties.
|
||||
*
|
||||
* TRACES: UR-066 | IR-031, DR-112
|
||||
*
|
||||
* ## Why this is necessary
|
||||
*
|
||||
* MainActivity calls `enableEdgeToEdge()`, and the app targets SDK 36 —
|
||||
* edge-to-edge is mandatory from SDK 35 and the opt-out is ignored from SDK 36
|
||||
* — so the Tauri WebView always spans the whole window, underneath the status
|
||||
* bar, the navigation/gesture bar and the display cutout.
|
||||
*
|
||||
* The web layer cannot discover that by itself. Android WebView maps only the
|
||||
* **display cutout** into `env(safe-area-inset-*)` (and only with
|
||||
* `viewport-fit=cover`); the status bar and navigation bar are never reported.
|
||||
* Unlike iOS Safari there is no CSS-visible system-bar inset. So the frontend's
|
||||
* `env()`-based padding evaluated to 0 on every device and the bottom nav
|
||||
* rendered underneath the navigation bar.
|
||||
*
|
||||
* How badly that showed depended entirely on the device's navigation mode: a
|
||||
* thin translucent gesture pill overlaps almost harmlessly, while a tall opaque
|
||||
* 3-button bar swallows the nav outright.
|
||||
*
|
||||
* ## Contract with the frontend
|
||||
*
|
||||
* Insets are reported in **CSS pixels** (density-independent), because that is
|
||||
* the unit CSS will use them in — dividing by `displayMetrics.density` here is
|
||||
* what keeps the padding correct across screen densities.
|
||||
*
|
||||
* - **Push**: on every inset change (rotation, navigation-mode switch, PiP
|
||||
* enter/exit) the four `--jt-inset-*` custom properties are written onto
|
||||
* `document.documentElement` and `jellytau-insets-changed` is dispatched.
|
||||
* - **Pull**: `AndroidInsets.get()` returns the same payload as JSON. Required
|
||||
* because the first inset pass normally lands before the SvelteKit document
|
||||
* exists, and a page load discards any inline style a push had set.
|
||||
*
|
||||
* See `src/lib/utils/safeArea.ts` and the `--safe-*` vars in `src/app.css`.
|
||||
*/
|
||||
object WindowInsetsBridge {
|
||||
|
||||
/** Latest insets in CSS pixels. Written on the main thread, read from the WebView binder thread. */
|
||||
@Volatile
|
||||
private var top = 0
|
||||
@Volatile
|
||||
private var right = 0
|
||||
@Volatile
|
||||
private var bottom = 0
|
||||
@Volatile
|
||||
private var left = 0
|
||||
|
||||
/** Cached so a WebView found later (or re-found on resume) can be primed. */
|
||||
private var webView: WebView? = null
|
||||
|
||||
/**
|
||||
* Start listening for window insets on [activity].
|
||||
*
|
||||
* Call from `onCreate` right after `enableEdgeToEdge()`. The listener
|
||||
* returns the insets **unconsumed** so the WebView still receives them for
|
||||
* its own display-cutout handling.
|
||||
*/
|
||||
fun install(activity: Activity) {
|
||||
val density = activity.resources.displayMetrics.density
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(activity.window.decorView) { _, insets ->
|
||||
// systemBars() covers the status bar and the navigation/gesture bar;
|
||||
// displayCutout() covers notches and punch-holes, which in landscape
|
||||
// land on a side edge that systemBars() does not describe.
|
||||
val i = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
|
||||
val toCssPx = { px: Int -> if (density > 0f) Math.round(px / density) else px }
|
||||
top = toCssPx(i.top)
|
||||
right = toCssPx(i.right)
|
||||
bottom = toCssPx(i.bottom)
|
||||
left = toCssPx(i.left)
|
||||
|
||||
android.util.Log.d(
|
||||
"WindowInsetsBridge",
|
||||
"insets (css px): top=$top right=$right bottom=$bottom left=$left"
|
||||
)
|
||||
push()
|
||||
|
||||
// Do NOT return CONSUMED - other views (and the WebView's own cutout
|
||||
// handling) still need to see these.
|
||||
insets
|
||||
}
|
||||
|
||||
// The first pass may already have happened before the listener existed.
|
||||
ViewCompat.requestApplyInsets(activity.window.decorView)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the WebView carrying the UI and push the current insets into it.
|
||||
*
|
||||
* Safe to call repeatedly (MainActivity re-finds the WebView on every
|
||||
* resume): this only writes CSS properties, unlike `addJavascriptInterface`,
|
||||
* which must run exactly once per WebView.
|
||||
*/
|
||||
fun attachWebView(view: WebView) {
|
||||
webView = view
|
||||
push()
|
||||
}
|
||||
|
||||
/** Current insets as JSON in CSS pixels — the payload `AndroidInsets.get()` returns. */
|
||||
fun currentJson(): String =
|
||||
"""{"top":$top,"right":$right,"bottom":$bottom,"left":$left}"""
|
||||
|
||||
/** The `AndroidInsets` @JavascriptInterface object for the pull path. */
|
||||
fun jsInterface(): Any = object : Any() {
|
||||
@JavascriptInterface
|
||||
fun get(): String = currentJson()
|
||||
}
|
||||
|
||||
/** Write the custom properties into the live document and signal the change. */
|
||||
private fun push() {
|
||||
val view = webView ?: return
|
||||
val js = """
|
||||
(function() {
|
||||
var s = document.documentElement.style;
|
||||
s.setProperty('--jt-inset-top', '${top}px');
|
||||
s.setProperty('--jt-inset-right', '${right}px');
|
||||
s.setProperty('--jt-inset-bottom', '${bottom}px');
|
||||
s.setProperty('--jt-inset-left', '${left}px');
|
||||
window.dispatchEvent(new CustomEvent('jellytau-insets-changed'));
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
view.post { view.evaluateJavascript(js, null) }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme -->
|
||||
<!--
|
||||
Base application theme.
|
||||
|
||||
This app is EDGE-TO-EDGE: MainActivity calls enableEdgeToEdge(), and
|
||||
targeting SDK 36 makes it mandatory anyway (enforced from SDK 35, with the
|
||||
opt-out ignored from SDK 36). The WebView therefore spans the whole window,
|
||||
under the status bar, the navigation/gesture bar and the display cutout.
|
||||
|
||||
This theme used to declare `android:fitsSystemWindows=true` with a "don't
|
||||
draw behind system bars" comment. That was never true: enableEdgeToEdge()
|
||||
calls setDecorFitsSystemWindows(false) at runtime and wins, and the
|
||||
platform ignores the attribute at this target SDK regardless. Leaving it
|
||||
in only hid the fact that nothing was insetting the content.
|
||||
|
||||
Insets are handled where they can actually be honoured: WindowInsetsBridge
|
||||
reads them and hands them to CSS as jt-inset custom properties.
|
||||
See UR-066 / DR-112.
|
||||
-->
|
||||
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Status bar color -->
|
||||
<!-- System bars are transparent; the app draws its own background behind
|
||||
them (e.g. BottomUi's surface extends under the gesture bar). -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<!-- Make status bar icons dark or light based on background -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<!-- Light icons on our dark background, both bars. -->
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||
<!-- Don't draw behind status bar -->
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<!-- Ensure content doesn't extend into system bars -->
|
||||
<item name="android:fitsSystemWindows">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
+31
-5
@@ -14,6 +14,37 @@
|
||||
--color-surface-hover: #252525;
|
||||
}
|
||||
|
||||
/* Safe-area insets — the single source of edge padding for the whole app.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112
|
||||
*
|
||||
* Two independent sources have to be folded together:
|
||||
*
|
||||
* - `env(safe-area-inset-*)` — iOS/desktop, and the *display cutout* on
|
||||
* Android. Requires `viewport-fit=cover` (see src/app.html) or it is 0px.
|
||||
* - `var(--jt-inset-*)` — real Android `WindowInsets` (status bar, navigation/
|
||||
* gesture bar, cutout) pushed in from Kotlin, because Android WebView never
|
||||
* reports the *system bars* through `env()`. See WindowInsetsBridge.kt and
|
||||
* $lib/utils/safeArea.ts.
|
||||
*
|
||||
* `max()` takes whichever is real on this platform; both are 0 on desktop.
|
||||
* Consumers must use `--safe-*` and never `env()` directly — a bare `env()` is
|
||||
* silently 0 for the Android system bars, which is what put the bottom nav
|
||||
* under the navigation bar on 3-button-nav devices.
|
||||
*
|
||||
* Applied at the edges that own them: the app shell (top/left/right) and
|
||||
* BottomUi (bottom, so its surface colour extends behind the gesture bar).
|
||||
* Deliberately NOT applied to `body` — the shell is `h-screen`, and body
|
||||
* padding would push 100vh past the viewport, and `position: fixed` overlays
|
||||
* (the video/audio players) ignore body padding anyway.
|
||||
*/
|
||||
:root {
|
||||
--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
|
||||
--safe-right: max(env(safe-area-inset-right, 0px), var(--jt-inset-right, 0px));
|
||||
--safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--jt-inset-bottom, 0px));
|
||||
--safe-left: max(env(safe-area-inset-left, 0px), var(--jt-inset-left, 0px));
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
html, body {
|
||||
@apply h-full;
|
||||
@@ -23,9 +54,4 @@ html, body {
|
||||
body {
|
||||
@apply text-white antialiased;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* Handle safe areas for mobile devices (status bar, notches, etc.) */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
+10
-1
@@ -3,7 +3,16 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!--
|
||||
`viewport-fit=cover` is REQUIRED: without it every `env(safe-area-inset-*)`
|
||||
resolves to 0px, so the safe-area padding in app.css/BottomUi is a no-op
|
||||
and the bottom nav renders under the Android navigation bar. See
|
||||
$lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars).
|
||||
-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>JellyTau</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -11,9 +11,16 @@
|
||||
"last row hidden behind the nav" bug. There is nothing to measure or reserve:
|
||||
the browser's flex layout does it exactly, every frame.
|
||||
|
||||
The Android system gesture bar is cleared via `env(safe-area-inset-bottom)`.
|
||||
The Android navigation/gesture bar is cleared via `--safe-bottom` (see
|
||||
app.css). The padding sits INSIDE this element's `bg-surface` box on purpose,
|
||||
so the surface colour extends behind the gesture bar instead of leaving a
|
||||
strip of page background under the nav.
|
||||
|
||||
TRACES: UR-005 | DR-009
|
||||
Never pad from a bare CSS `env()` safe-area value here: Android WebView does
|
||||
not report the system bars that way, so it is always 0 and the nav ends up
|
||||
under the navigation bar (UR-066).
|
||||
|
||||
TRACES: UR-005, UR-066 | DR-009, DR-112
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -41,7 +48,7 @@
|
||||
</script>
|
||||
|
||||
<!-- flex-shrink-0 so it keeps its natural height; the scroller sibling flexes. -->
|
||||
<div class="flex-shrink-0 pb-[env(safe-area-inset-bottom)] bg-[var(--color-surface)]">
|
||||
<div class="flex-shrink-0 pb-[var(--safe-bottom)] bg-[var(--color-surface)]">
|
||||
{#if showMiniPlayer}
|
||||
<MiniPlayer
|
||||
media={$currentMedia}
|
||||
|
||||
@@ -154,8 +154,16 @@
|
||||
<div class="fixed inset-0 z-0 bg-[var(--color-background)]"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Content overlay -->
|
||||
<div class="relative z-10 flex flex-col h-full">
|
||||
<!-- Content overlay. The blurred artwork behind it stays edge-to-edge; only
|
||||
this layer is inset, so the close button clears the status bar and the
|
||||
transport controls clear the Android gesture bar. (UR-066) -->
|
||||
<div
|
||||
class="relative z-10 flex flex-col h-full"
|
||||
style:padding-top="var(--safe-top)"
|
||||
style:padding-bottom="var(--safe-bottom)"
|
||||
style:padding-left="var(--safe-left)"
|
||||
style:padding-right="var(--safe-right)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button
|
||||
@@ -329,8 +337,12 @@
|
||||
aria-label="Close queue"
|
||||
></button>
|
||||
|
||||
<!-- Queue Panel -->
|
||||
<div class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up">
|
||||
<!-- Queue Panel. Slides up from the very bottom, so it owns the bottom
|
||||
inset — its last row would otherwise sit under the gesture bar. -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up"
|
||||
style:padding-bottom="var(--safe-bottom)"
|
||||
>
|
||||
<Queue
|
||||
items={$queueItems}
|
||||
currentIndex={$currentQueueIndex}
|
||||
|
||||
@@ -1869,7 +1869,13 @@
|
||||
returned actors for the current timestamp. Tapping an actor with a
|
||||
resolved Jellyfin Person id opens their library page. -->
|
||||
{#if !isPlaying && !isSeeking && jrayActors.length > 0}
|
||||
<div class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto">
|
||||
<!-- Offset by the safe-area insets so the card clears the status bar and,
|
||||
in landscape, the display cutout. (UR-066) -->
|
||||
<div
|
||||
class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto"
|
||||
style:top="calc(1rem + var(--safe-top))"
|
||||
style:right="calc(1rem + var(--safe-right))"
|
||||
>
|
||||
<div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each jrayActors as actor (actor.name + actor.jellyfin_id)}
|
||||
@@ -1905,10 +1911,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
||||
container-level tap gestures ignore touches here (see DR-098). -->
|
||||
container-level tap gestures ignore touches here (see DR-098).
|
||||
|
||||
The video itself deliberately fills the whole screen (edge-to-edge, under
|
||||
the cutout), but every interactive control lives in here — so this box,
|
||||
not the video, carries the safe-area insets. Without them the scrub bar
|
||||
and the close/fullscreen buttons sit under the Android gesture bar, and
|
||||
in landscape under the display cutout. (UR-066) -->
|
||||
<div
|
||||
data-player-controls
|
||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
||||
style:padding-bottom="calc(1rem + var(--safe-bottom))"
|
||||
style:padding-left="calc(1rem + var(--safe-left))"
|
||||
style:padding-right="calc(1rem + var(--safe-right))"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
showGlobalHeader,
|
||||
routeOwnsLayout,
|
||||
showBottomUi,
|
||||
shellReservesBottomInset,
|
||||
} from "./layoutShell";
|
||||
|
||||
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
|
||||
@@ -149,3 +150,32 @@ describe("structural invariant: every route that shows bottom UI has a scroller
|
||||
expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Who owns the bottom safe-area inset.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112 | UT-098
|
||||
*
|
||||
* Exactly one element must reserve `--safe-bottom`, or the Android gesture bar
|
||||
* is either ignored (nav swallowed) or double-padded (a dead strip above it).
|
||||
* BottomUi owns it whenever it renders — the padding sits inside its surface
|
||||
* box so the colour extends behind the bar. Routes with no BottomUi (login, the
|
||||
* full-screen player) leave the app shell to reserve it instead.
|
||||
*/
|
||||
describe("shellReservesBottomInset", () => {
|
||||
it("defers to BottomUi on every route that renders one", () => {
|
||||
for (const pathname of ["/", "/search", "/downloads", "/settings", "/library"]) {
|
||||
expect(showBottomUi(authed(pathname))).toBe(true);
|
||||
expect(shellReservesBottomInset(authed(pathname))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("reserves the inset itself on routes with no bottom UI", () => {
|
||||
expect(shellReservesBottomInset(authed("/login"))).toBe(true);
|
||||
expect(shellReservesBottomInset(authed("/player/x"))).toBe(true);
|
||||
});
|
||||
|
||||
it("defers on the unauthenticated shell, where the mini player still renders", () => {
|
||||
expect(shellReservesBottomInset({ pathname: "/", isAuthenticated: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,3 +98,19 @@ export function showGlobalHeader({
|
||||
export function showBottomUi(input: BottomUiVisibilityInput): boolean {
|
||||
return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app shell itself must reserve the bottom safe-area inset
|
||||
* (`--safe-bottom`, i.e. the Android navigation/gesture bar).
|
||||
*
|
||||
* Exactly one element may reserve it. BottomUi owns it whenever it renders,
|
||||
* because the padding belongs *inside* its surface box so the colour extends
|
||||
* behind the bar rather than leaving a strip of page background. On routes with
|
||||
* no bottom UI at all (login, the full-screen player) nothing else would, so
|
||||
* the shell takes it.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112
|
||||
*/
|
||||
export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean {
|
||||
return !showBottomUi(input);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
ZERO_INSETS,
|
||||
parseNativeInsets,
|
||||
safeAreaCssVars,
|
||||
applySafeAreaInsets,
|
||||
readNativeInsets,
|
||||
initSafeArea,
|
||||
INSETS_CHANGED_EVENT,
|
||||
} from "./safeArea";
|
||||
|
||||
/**
|
||||
* Safe-area (window inset) plumbing for Android.
|
||||
*
|
||||
* TRACES: UR-066 | IR-031, DR-112 | UT-094, UT-095, UT-096, UT-097
|
||||
*
|
||||
* Regression guard for "the bottom nav is off the bottom of the screen on some
|
||||
* devices (Motorola) but not others (Fairphone)".
|
||||
*
|
||||
* Two independent defects produced it:
|
||||
*
|
||||
* 1. `src/app.html` shipped `<meta name="viewport" content="width=device-width,
|
||||
* initial-scale=1">` — no `viewport-fit=cover`. Per the CSS Env spec, every
|
||||
* `env(safe-area-inset-*)` resolves to **0px** unless the viewport opts into
|
||||
* `cover`. So the `env()` padding in app.css and BottomUi.svelte was a
|
||||
* no-op on every device.
|
||||
* 2. Even with `viewport-fit=cover`, Android WebView only maps the **display
|
||||
* cutout** into `env(safe-area-inset-*)` — never the status bar or the
|
||||
* navigation/gesture bar. MainActivity calls `enableEdgeToEdge()` and the
|
||||
* app targets SDK 36 (edge-to-edge is mandatory from SDK 35 and the opt-out
|
||||
* is ignored from SDK 36), so the WebView always spans the full window
|
||||
* including the system bars. CSS alone can never learn about them.
|
||||
*
|
||||
* The device split was only in how much the bars intrude: a thin translucent
|
||||
* gesture pill overlaps harmlessly, a tall opaque 3-button bar swallows the nav
|
||||
* outright. Both devices were equally unpadded.
|
||||
*
|
||||
* The fix pushes real `WindowInsets` from Kotlin into CSS custom properties.
|
||||
* These tests pin the frontend half of that contract.
|
||||
*/
|
||||
describe("parseNativeInsets", () => {
|
||||
it("parses the JSON payload the native bridge returns", () => {
|
||||
expect(parseNativeInsets('{"top":24,"right":0,"bottom":48,"left":0}')).toEqual({
|
||||
top: 24,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
left: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an already-parsed object", () => {
|
||||
expect(parseNativeInsets({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual({
|
||||
top: 1,
|
||||
right: 2,
|
||||
bottom: 3,
|
||||
left: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats missing, non-finite and negative edges as zero rather than emitting NaN", () => {
|
||||
// A NaN would serialise to "NaNpx" and silently kill the whole padding
|
||||
// declaration, which is exactly the failure mode being guarded against.
|
||||
expect(parseNativeInsets('{"top":-5,"bottom":"48"}')).toEqual({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
left: 0,
|
||||
});
|
||||
expect(parseNativeInsets({ top: Number.NaN, right: Infinity })).toEqual(ZERO_INSETS);
|
||||
});
|
||||
|
||||
it("returns null for input that is not an inset payload at all", () => {
|
||||
expect(parseNativeInsets("not json")).toBeNull();
|
||||
expect(parseNativeInsets(null)).toBeNull();
|
||||
expect(parseNativeInsets(undefined)).toBeNull();
|
||||
expect(parseNativeInsets(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeAreaCssVars / applySafeAreaInsets", () => {
|
||||
it("emits px-suffixed custom properties for all four edges", () => {
|
||||
expect(safeAreaCssVars({ top: 24, right: 0, bottom: 48, left: 12 })).toEqual({
|
||||
"--jt-inset-top": "24px",
|
||||
"--jt-inset-right": "0px",
|
||||
"--jt-inset-bottom": "48px",
|
||||
"--jt-inset-left": "12px",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the custom properties onto the target element", () => {
|
||||
const el = document.createElement("div");
|
||||
applySafeAreaInsets(el, { top: 24, right: 1, bottom: 48, left: 2 });
|
||||
|
||||
expect(el.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-right")).toBe("1px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-left")).toBe("2px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readNativeInsets", () => {
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidInsets;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when the bridge is absent (desktop, iOS, dev server)", () => {
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
});
|
||||
|
||||
it("reads and parses the bridge payload", () => {
|
||||
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
|
||||
expect(readNativeInsets()).toEqual({ top: 24, right: 0, bottom: 48, left: 0 });
|
||||
});
|
||||
|
||||
it("returns null when the bridge object is a stale WebView proxy", () => {
|
||||
// Same failure mode as the background-audio bridge: the injected object
|
||||
// stays truthy across a page load while its methods vanish. Must not throw
|
||||
// out of layout init.
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
window.AndroidInsets = {} as unknown as { get(): string };
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
|
||||
window.AndroidInsets = {
|
||||
get: () => {
|
||||
throw new TypeError("get is not a function");
|
||||
},
|
||||
};
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSafeArea", () => {
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidInsets;
|
||||
document.documentElement.removeAttribute("style");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
});
|
||||
|
||||
it("primes the document element from the bridge on start", () => {
|
||||
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
|
||||
|
||||
stop = initSafeArea();
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
|
||||
});
|
||||
|
||||
it("re-applies insets when native reports a change (rotation, nav-mode switch)", () => {
|
||||
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
|
||||
window.AndroidInsets = { get: () => payload };
|
||||
|
||||
stop = initSafeArea();
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
|
||||
// Rotated to landscape: the cutout moves to the left edge, the gesture bar
|
||||
// shrinks. Native re-pushes and fires the change event.
|
||||
payload = '{"top":0,"right":0,"bottom":24,"left":44}';
|
||||
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("0px");
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-left")).toBe("44px");
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("24px");
|
||||
});
|
||||
|
||||
it("leaves the custom properties unset with no bridge, so env() keeps the field", () => {
|
||||
// On iOS/desktop the `env(safe-area-inset-*)` half of the max() must win;
|
||||
// writing an explicit 0px here would clobber it.
|
||||
stop = initSafeArea();
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("");
|
||||
});
|
||||
|
||||
it("stops listening once torn down", () => {
|
||||
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
|
||||
window.AndroidInsets = { get: () => payload };
|
||||
|
||||
const teardown = initSafeArea();
|
||||
teardown();
|
||||
|
||||
payload = '{"top":99,"right":99,"bottom":99,"left":99}';
|
||||
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Static guards. The two root-cause defects were both single lines of markup /
|
||||
* CSS that no runtime test could reach, so pin them at the source level.
|
||||
*/
|
||||
describe("safe-area wiring in source", () => {
|
||||
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), "utf8");
|
||||
|
||||
it("app.html opts the viewport into viewport-fit=cover", () => {
|
||||
const viewport = read("src/app.html").match(/<meta\s+name="viewport"[^>]*>/i)?.[0];
|
||||
|
||||
expect(viewport, "no viewport meta tag found in src/app.html").toBeTruthy();
|
||||
expect(viewport).toMatch(/viewport-fit\s*=\s*cover/);
|
||||
});
|
||||
|
||||
it("app.css derives --safe-* from both env() and the native --jt-inset-* vars", () => {
|
||||
const css = read("src/app.css");
|
||||
|
||||
for (const edge of ["top", "right", "bottom", "left"]) {
|
||||
expect(css).toMatch(
|
||||
new RegExp(
|
||||
`--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("no component pads directly from env() — everything goes through --safe-*", () => {
|
||||
// A bare env() is 0 in Android WebView for the system bars, which is the
|
||||
// bug. app.css is the one legal place it may appear (inside the max()).
|
||||
const offenders = [
|
||||
"src/lib/components/BottomUi.svelte",
|
||||
"src/routes/+layout.svelte",
|
||||
"src/lib/components/player/VideoPlayer.svelte",
|
||||
"src/lib/components/player/AudioPlayer.svelte",
|
||||
].filter((f) => read(f).includes("env(safe-area-inset"));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the bottom UI reserves the bottom inset so the nav clears the gesture bar", () => {
|
||||
expect(read("src/lib/components/BottomUi.svelte")).toMatch(/pb-\[var\(--safe-bottom\)\]/);
|
||||
});
|
||||
|
||||
it("only the app shell measures itself against the viewport", () => {
|
||||
// The shell is `h-screen` AND inset-padded, so its content box is
|
||||
// `100vh - safe-top`. Any nested `h-screen`/`min-h-screen` is therefore
|
||||
// taller than the space it was given and overflows by exactly the inset —
|
||||
// the library column's `h-screen` clipped its own BottomUi that way. Nested
|
||||
// full-height boxes must use `h-full`/`min-h-full` and inherit the shell's
|
||||
// already-inset height.
|
||||
const svelteFilesIn = (dir: string): string[] =>
|
||||
readdirSync(resolve(process.cwd(), dir), { recursive: true, encoding: "utf8" })
|
||||
.filter((f) => f.endsWith(".svelte"))
|
||||
.map((f) => `${dir}/${f}`);
|
||||
|
||||
const offenders = [...svelteFilesIn("src/routes"), ...svelteFilesIn("src/lib/components")]
|
||||
.filter((f) => f !== "src/routes/+layout.svelte")
|
||||
.filter((f) => /class=[^>]*\bh-screen\b|class=[^>]*\bmin-h-screen\b/.test(read(f)));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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<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) {
|
||||
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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user