Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
125 lines
4.0 KiB
TypeScript
125 lines
4.0 KiB
TypeScript
/**
|
|
* Picture-in-picture support, Android only.
|
|
*
|
|
* TRACES: UR-041 | IR-026 | DR-053
|
|
*
|
|
* Video on Android renders into a native ExoPlayer SurfaceView behind the
|
|
* WebView, so PiP is driven by the Activity (which shrinks into a floating
|
|
* window) rather than the HTML5 `requestPictureInPicture()` API. The bridge is
|
|
* the `AndroidPictureInPicture` @JavascriptInterface installed by MainActivity.
|
|
*
|
|
* On every other platform this module reports unsupported. Notably WebKitGTK
|
|
* (the Linux webview) does not implement the Picture-in-Picture Web API at all,
|
|
* so there is no HTML5 fallback to reach for.
|
|
*/
|
|
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("PiP");
|
|
|
|
interface AndroidPictureInPictureBridge {
|
|
enterPip(): void;
|
|
isSupported(): boolean;
|
|
canEnterPip(): boolean;
|
|
setAutoEnterEnabled(enabled: boolean): void;
|
|
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
AndroidPictureInPicture?: AndroidPictureInPictureBridge;
|
|
}
|
|
}
|
|
|
|
function bridge(): AndroidPictureInPictureBridge | undefined {
|
|
if (typeof window === "undefined") return undefined;
|
|
return window.AndroidPictureInPicture;
|
|
}
|
|
|
|
/**
|
|
* Whether the device can do PiP at all - used to decide if the button should
|
|
* be rendered. False on desktop, and on Android devices where the user has
|
|
* disabled the feature.
|
|
*/
|
|
export function isPipSupported(): boolean {
|
|
try {
|
|
return bridge()?.isSupported() ?? false;
|
|
} catch (err) {
|
|
log.warn("isSupported check failed:", err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether entering PiP would succeed right now: a native video must be playing
|
|
* locally. False during audio playback and while casting to a remote session.
|
|
*/
|
|
export function canEnterPip(): boolean {
|
|
try {
|
|
return bridge()?.canEnterPip() ?? false;
|
|
} catch (err) {
|
|
log.warn("canEnterPip check failed:", err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Enter picture-in-picture. No-op where unsupported. */
|
|
export function enterPip(): void {
|
|
try {
|
|
bridge()?.enterPip();
|
|
} catch (err) {
|
|
log.error("Failed to enter picture-in-picture:", err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable/disable auto-entering PiP when the user backgrounds the app.
|
|
*
|
|
* This is a coarse frontend override; the authoritative gate is the native
|
|
* `canEnterPip` guard, which already refuses PiP unless a local video surface
|
|
* is actively rendering (so audio playback, menu/library browsing, and
|
|
* remote/cast sessions never enter PiP regardless of this flag). The only
|
|
* caller today is the background-audio toggle, which disarms auto-PiP so the
|
|
* two background behaviours stay mutually exclusive.
|
|
*/
|
|
export function setAutoEnterEnabled(enabled: boolean): void {
|
|
try {
|
|
bridge()?.setAutoEnterEnabled(enabled);
|
|
} catch (err) {
|
|
log.warn("Failed to set auto-enter:", err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
|
|
*
|
|
* This is what makes PiP work on the HTML5 path. The native side only ever knew
|
|
* about the ExoPlayer surface, and that path is behind `experimentalNativeVideo`,
|
|
* which defaulted to off when this was written — so `canEnterPip` was always
|
|
* false and pressing the button did nothing. Reporting the element's state gives
|
|
* native a surface it can legitimately shrink into, plus the intrinsic size it
|
|
* needs for the PiP window's aspect ratio and the play state for its play/pause
|
|
* action.
|
|
*
|
|
* The flag is back to defaulting **off** (DR-172, after native video shipped as
|
|
* audio with no picture), so this is once again the path Android normally takes —
|
|
* which is why PiP does not depend on that flag being on.
|
|
*
|
|
* Pass `active: false` when the element goes away, or PiP would be offered over a
|
|
* video that is no longer there.
|
|
*
|
|
* TRACES: UR-041 | DR-160
|
|
*/
|
|
export function setHtml5VideoState(
|
|
active: boolean,
|
|
width: number,
|
|
height: number,
|
|
playing: boolean,
|
|
): void {
|
|
try {
|
|
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
|
|
} catch (err) {
|
|
log.warn("Failed to report HTML5 video state:", err);
|
|
}
|
|
}
|