fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3.
This commit is contained in:
@@ -38,7 +38,13 @@
|
||||
enableNativeVideoCompositing,
|
||||
disableNativeVideoCompositing,
|
||||
} from "$lib/utils/videoSurface";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
isPipSupported,
|
||||
enterPip,
|
||||
setAutoEnterEnabled,
|
||||
setHtml5VideoState,
|
||||
} from "$lib/utils/pictureInPicture";
|
||||
import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
|
||||
import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
@@ -109,8 +115,39 @@
|
||||
endedFired = true;
|
||||
onEnded?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep native's picture-in-picture state in step with the `<video>` element.
|
||||
*
|
||||
* PiP is driven by the Activity, and it only ever knew about the native
|
||||
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which defaults
|
||||
* to off. So in the shipping configuration nothing satisfied its "is a video
|
||||
* playing?" check and the PiP button did nothing at all. Reporting the element
|
||||
* gives it a surface it can shrink into. (UR-041, DR-160)
|
||||
*/
|
||||
function reportPipVideoState() {
|
||||
if (!useHtml5Element || !videoElement) {
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
return;
|
||||
}
|
||||
setHtml5VideoState(
|
||||
true,
|
||||
videoElement.videoWidth,
|
||||
videoElement.videoHeight,
|
||||
isPlaying
|
||||
);
|
||||
}
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
/**
|
||||
* True while the Activity is in picture-in-picture.
|
||||
*
|
||||
* On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
|
||||
* itself down to the video — controls, header and gradients would otherwise be
|
||||
* rendered into a window a couple of inches wide. (UR-041, DR-160)
|
||||
*/
|
||||
let isInPip = $state(false);
|
||||
let pipListenerCleanup: (() => void) | null = null;
|
||||
let showSleepTimerModal = $state(false);
|
||||
let isBuffering = $state(false);
|
||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -810,6 +847,24 @@
|
||||
// Load series audio preference (for TV shows)
|
||||
await loadSeriesAudioPreference();
|
||||
|
||||
// PiP: keep native's view of the `<video>` current, and react to the window
|
||||
// shrinking. The listeners are torn down in onDestroy. (DR-160)
|
||||
reportPipVideoState();
|
||||
const onPipEntered = () => (isInPip = true);
|
||||
const onPipExited = () => (isInPip = false);
|
||||
const onPipPlay = () => void videoElement?.play().catch(() => {});
|
||||
const onPipPause = () => videoElement?.pause();
|
||||
window.addEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.addEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.addEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.addEventListener("jellytau-pip-pause", onPipPause);
|
||||
pipListenerCleanup = () => {
|
||||
window.removeEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.removeEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.removeEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.removeEventListener("jellytau-pip-pause", onPipPause);
|
||||
};
|
||||
|
||||
// Report progress every 10 seconds while playing. Live streams have no
|
||||
// meaningful position to report, so skip progress reporting entirely.
|
||||
if (!isLive) {
|
||||
@@ -855,6 +910,16 @@
|
||||
// and idempotent — a no-op when compositing was never enabled.
|
||||
disableNativeVideoCompositing();
|
||||
|
||||
// Same reasoning for the system bars: they belong to the Activity, not to
|
||||
// this component, so a player torn down while immersive would leave every
|
||||
// screen behind it without a status or navigation bar. Idempotent. (UR-066)
|
||||
exitImmersive();
|
||||
|
||||
// The `<video>` is going away, so PiP must stop being offered over it.
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
pipListenerCleanup?.();
|
||||
pipListenerCleanup = null;
|
||||
|
||||
// Stop RAF loop
|
||||
stopTimeUpdates();
|
||||
|
||||
@@ -968,6 +1033,9 @@
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
console.log("[VideoPlayer] loadedmetadata event");
|
||||
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
||||
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
||||
reportPipVideoState();
|
||||
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
||||
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
||||
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
||||
@@ -1239,6 +1307,8 @@
|
||||
function handlePlay() {
|
||||
isPlaying = true;
|
||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||
// PiP's play/pause action reflects this. (DR-160)
|
||||
reportPipVideoState();
|
||||
// Mirror the DOM state into the Rust PlayerController so it is the single
|
||||
// source of truth for HTML5 video (the <video> lives in the webview, which
|
||||
// Rust cannot observe directly). See html5Adapter.ts.
|
||||
@@ -1268,6 +1338,7 @@
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
|
||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
||||
// Report progress when paused
|
||||
@@ -1528,12 +1599,24 @@
|
||||
let pendingForegroundSeek: number | null = null;
|
||||
let pendingForegroundPlay = false;
|
||||
|
||||
// On Android the Activity owns the system bars, and requestFullscreen() cannot
|
||||
// reach them — the WebView already spans the window under an edge-to-edge
|
||||
// Activity, so on its own it left the status and navigation bars painted over
|
||||
// the video. The native bridge is what actually makes fullscreen full screen;
|
||||
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||
// the immersive call below is what matters on Android, so don't let a
|
||||
// rejection here abort it.
|
||||
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
||||
});
|
||||
enterImmersive();
|
||||
isFullscreen = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
exitImmersive();
|
||||
isFullscreen = false;
|
||||
}
|
||||
}
|
||||
@@ -1593,7 +1676,9 @@
|
||||
toggleFullscreen();
|
||||
} else if (e.key === "Escape") {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen();
|
||||
// Through the toggle, not document.exitFullscreen() directly: leaving
|
||||
// fullscreen also has to restore the system bars and clear the flag.
|
||||
toggleFullscreen();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
@@ -2090,8 +2175,8 @@
|
||||
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}
|
||||
class:opacity-0={!showControls || isInPip}
|
||||
class:pointer-events-none={!showControls || isInPip}
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="mb-2">
|
||||
|
||||
Reference in New Issue
Block a user