Android picture-in-picture, and fix three dead Android config files
Add PiP for native (ExoPlayer) video on Android. Video renders into a SurfaceView behind the WebView, so PiP is driven by the Activity shrinking into a floating window rather than the HTML5 PiP API (which WebKitGTK does not implement, hence Android-only). - PictureInPictureManager.kt: enter PiP with the video's aspect ratio (clamped to the 1:2.39-2.39:1 range Android accepts, outside which it throws), plus a play/pause RemoteAction. Hides the WebView while in PiP - it is opaque and sits above the surface, so it would otherwise occlude the video entirely - and re-fits the surface on exit. - MainActivity.kt: onUserLeaveHint auto-PiP, onPictureInPictureModeChanged, and an AndroidPictureInPicture JS interface following the existing AndroidAudioFocus pattern. - pictureInPicture.ts + VideoPlayer.svelte: PiP button, rendered only when the native bridge reports support. - proguard: keep rules for @JavascriptInterface methods, which are only referenced from JS and would be stripped in minified release builds. Casting needs no special handling: canEnterPip() checks natively that a local video surface is attached and playing, which a remote session lacks. While wiring the manifest, found that three tracked files under src-tauri/android/ were never reaching any build. Gradle reads only gen/android/app/src/main/, and sync-android-sources.sh did not copy them: - src/main/AndroidManifest.xml was a partial <application> fragment written as if Tauri merged it. It does not - there is no manifest-merger hook here, so its hardwareAccelerated flag never reached an APK. Promoted to the complete authoritative manifest (folding in that flag) and synced. - src/main/res/values/themes.xml (transparent status bar, fitsSystemWindows) was never copied; the sync only globbed mipmap-*. Now synced. - build.gradle.kts was a leftover com.android.library module config with stale media3 1.5.1 deps. The live deps are in app/build.gradle.kts at 1.5.0. Deleted. Verified: merged manifest now carries hardwareAccelerated, supportsPictureInPicture, resizeableActivity and the density configChange; themes.xml compiles into merged resources; Kotlin builds warning-free; svelte-check clean; 537 frontend tests pass. Not verified: PiP behaviour on a device, and the release keep rules against a minified build. assembleUniversalDebug cannot complete in this environment - the Rust step wants a dev-server addr file that only exists under `tauri android dev`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
import { playerController } from "$lib/player";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip } from "$lib/utils/pictureInPicture";
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -1081,6 +1082,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved once at component setup: the PiP bridge is installed by
|
||||
// MainActivity before the page loads and never changes for the session.
|
||||
// Synchronous by design - no await in onMount (see VideoPlayer native-mode
|
||||
// pitfalls: awaiting there flips the component into HTML5 mode).
|
||||
const pipSupported = isPipSupported();
|
||||
|
||||
function handlePictureInPicture() {
|
||||
enterPip();
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
@@ -1721,6 +1732,19 @@
|
||||
<!-- Volume Control -->
|
||||
<VolumeControl size="md" />
|
||||
|
||||
<!-- Picture-in-picture (Android only) -->
|
||||
{#if pipSupported}
|
||||
<button
|
||||
onclick={handlePictureInPicture}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Picture in picture"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Fullscreen -->
|
||||
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Picture-in-picture support, Android only.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface AndroidPictureInPictureBridge {
|
||||
enterPip(): void;
|
||||
isSupported(): boolean;
|
||||
canEnterPip(): boolean;
|
||||
setAutoEnterEnabled(enabled: 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) {
|
||||
console.warn("[PiP] 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) {
|
||||
console.warn("[PiP] canEnterPip check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter picture-in-picture. No-op where unsupported. */
|
||||
export function enterPip(): void {
|
||||
try {
|
||||
bridge()?.enterPip();
|
||||
} catch (err) {
|
||||
console.error("[PiP] Failed to enter picture-in-picture:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable auto-entering PiP when the user backgrounds the app.
|
||||
*
|
||||
* Disabled while casting: playback is happening on another device, so a PiP
|
||||
* window here would render an empty black box.
|
||||
*/
|
||||
export function setAutoEnterEnabled(enabled: boolean): void {
|
||||
try {
|
||||
bridge()?.setAutoEnterEnabled(enabled);
|
||||
} catch (err) {
|
||||
console.warn("[PiP] Failed to set auto-enter:", err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user