fix(offline): keep the offline banner off the full-screen player

Every other shell rule in layoutShell.ts already treats /player/* as
immersive; the amber "You're offline" strip was the one piece of chrome
still rendered above it. On the native Android video path that is not
cosmetic: VideoPlayer makes itself transparent so the ExoPlayer
SurfaceView behind the WebView is visible (DR-185), so a shell child that
still paints shows through the picture as a stripe across the top of the
film. Offline is also precisely when a downloaded video plays, so the
banner appeared when it was most in the way — and it offers the viewer
nothing to act on, since local playback needs no server.

The rule moves into the pure module as showOfflineBanner() rather than
staying an inline {#if} in the shell, so the immersive-route contract is
stated in one tested place.

TRACES: UR-003, UR-043 | DR-291 | UT-255
This commit is contained in:
2026-09-22 21:08:03 -04:00
parent 0f928798d7
commit b98cbfe28a
4 changed files with 70 additions and 4 deletions
+29
View File
@@ -23,6 +23,7 @@ import {
routeOwnsLayout,
showBottomUi,
shellReservesBottomInset,
showOfflineBanner,
} from "./layoutShell";
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
@@ -236,3 +237,31 @@ describe("profile picker chrome", () => {
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
});
});
/**
* The offline banner is shell chrome, and the full-screen player is chrome-free
* like every other immersive route in this module. On the native Android video
* path VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind
* the WebView shows through (DR-185), which means any shell element that still
* paints — the amber banner — appears as a stripe across the top of the film.
*
* TRACES: UR-003 | DR-291 | UT-255
*/
describe("showOfflineBanner", () => {
it("shows while offline on ordinary routes", () => {
expect(showOfflineBanner({ ...authed("/"), isConnected: false })).toBe(true);
expect(showOfflineBanner({ ...authed("/library/movies"), isConnected: false })).toBe(true);
expect(showOfflineBanner({ ...authed("/downloads"), isConnected: false })).toBe(true);
});
it("stays off the full-screen player, where it would paint over the video", () => {
expect(showOfflineBanner({ ...authed("/player/abc123"), isConnected: false })).toBe(false);
});
it("stays off while connected, and while signed out", () => {
expect(showOfflineBanner({ ...authed("/"), isConnected: true })).toBe(false);
expect(showOfflineBanner({ pathname: "/", isAuthenticated: false, isConnected: false })).toBe(
false,
);
});
});
+23
View File
@@ -129,3 +129,26 @@ export function showBottomUi(input: BottomUiVisibilityInput): boolean {
export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean {
return !showBottomUi(input);
}
/**
* Whether the shell renders the offline banner ("You're offline…").
*
* The banner is shell chrome, and it is the last piece of it that still
* rendered over the full-screen player — every other rule in this module
* already treats `/player/*` as immersive. On the native Android video path
* that is not merely untidy: VideoPlayer makes itself transparent so the
* ExoPlayer SurfaceView behind the WebView can be seen (DR-185), so any shell
* element that still paints shows through the film. Offline is also exactly
* when a downloaded video plays, so the banner was most likely to be there
* precisely when it was most in the way — and it says nothing the viewer can
* act on while watching: local playback needs no server.
*
* TRACES: UR-003, UR-043 | DR-291
*/
export function showOfflineBanner({
pathname,
isAuthenticated,
isConnected,
}: BottomUiVisibilityInput & { isConnected: boolean }): boolean {
return isAuthenticated && !isConnected && !pathname.startsWith("/player/");
}