Compare commits

..
2 Commits
Author SHA1 Message Date
dtourolle 8a04a6fad0 chore(release): v0.11.5
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m41s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 14m44s
Build & Release / Build Linux (push) Successful in 20m30s
Build & Release / Build Windows (push) Successful in 15m38s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Waiting to run
One fix since v0.11.4: a video in a picture-in-picture window no longer drops
to audio-only partway through, replaying from wherever the picture had been
when the window opened.

Two independent faults had to line up for it. The player's position variable is
written only by a requestAnimationFrame loop while playing, and RAF stops for a
document that is not being rendered -- which an Android activity behind a PiP
window is not; the `timeupdate` handler that would have covered the gap had
gated itself on `!isPlaying` since the first commit. And PiP and the
background-audio handoff, nominally alternatives, could both be armed at once,
with a single `isInPictureInPictureMode` sample taken inside onStop() standing
between them.

The frozen position was not confined to the handoff: the seek bar, resume
points and the progress reported to Jellyfin all read the same variable, so all
three stood still for as long as a PiP window was open.

Version stamped with scripts/set-version.sh. defect-windows.md records DR-265
under the present-since/reachable-since gap: defective since v0.0.1, but only
hittable once PiP started working on the HTML5 path in v0.5.3.
2026-08-27 17:58:06 +02:00
dtourolle c0399e4ebd fix(player): stop PiP dropping the video and restarting the audio behind it
Watching in a picture-in-picture window would occasionally drop to audio-only,
and the audio would resume from wherever the video had been when PiP was
entered while the picture had carried on past it. Two independent faults, both
needed to produce that.

The position froze (DR-265). VideoPlayer tracks the absolute position in its
own `currentTime` rather than reading `videoElement.currentTime` at the point
of use, because transcoded HLS resets the element to 0 on every segment
rebuild. While playing, that variable had exactly one writer: a
requestAnimationFrame loop. RAF is driven by the document being rendered, and
an Android activity behind a PiP window is paused, so the loop stops while the
element plays on. The `timeupdate` handler that would have covered the gap was
written as a fallback "for when RAF isn't running" and gated itself on
`!isPlaying` -- switching itself off at precisely the moment it was the only
source left. Everything downstream froze with it: the seek bar, the ten-second
progress reports, the position mirrored into Rust, and the handoff. The gate is
now `shouldApplyTimeUpdate` and turns only on things that genuinely own the
position -- an in-flight seek, a seek-bar drag, an element below
HAVE_CURRENT_DATA. Both writers producing the same derived value costs nothing.

The handoff fired at all (DR-266). PiP and the background-audio handoff are
alternatives -- one keeps the picture, the other throws it away -- but
exclusivity was enforced from one side only: arming the toggle suppressed
*auto*-PiP, while the PiP button stayed ungated, so pressing it left both
armed. What then stood between them was `isInPictureInPictureMode`, sampled
once inside MainActivity.onStop(). That sample is not reliable: the keyguard
dismissing the window, the window being stashed, or OEM variance in when
onPictureInPictureModeChanged(false) lands can all leave the activity stopped
with a window still on screen and the flag reading false. Now entering PiP
disarms background audio, both directions go through one BackgroundBehaviour
pair, and the PiP question accepts either witness -- the native sample or the
frontend's latch over jellytau-pip-entered/exited. The latch cannot report a
window that has closed: both events reach the WebView through the same message
queue in dispatch order. The decision itself stays in Rust; the frontend only
supplies a fact it can establish more reliably than the activity can.

Red first, both: the existing behaviour was extracted into pure helpers, the
tests written against the correct behaviour, and both watched to fail before
either was changed.
2026-08-27 17:56:52 +02:00
13 changed files with 316 additions and 23 deletions
+23
View File
@@ -9,6 +9,29 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.11.5
### 🐛 Fixes
- **A video in a picture-in-picture window stays a video.** Watching in a PiP
window would sometimes drop to audio only, and the audio would pick up from
wherever the video had been when the window opened — while the picture itself
had carried on well past that. Two faults, both needed to produce it. The
player's idea of where it is in the video is kept by a loop that only runs
while the app is drawing to the screen, and behind a PiP window it is not:
the position quietly stopped advancing the moment the window opened, and the
one other source that could have kept it current had been written to switch
itself off during playback. Separately, PiP and the "keep the audio playing in
the background" toggle are meant to be alternatives, but only the toggle
enforced that — the PiP button could still be pressed with both armed, and the
single moment-in-time check meant to tell them apart is not always right about
whether a window is open. Opening PiP now turns background audio off, the app
trusts its own record of the window as well as the system's, and the position
keeps advancing whether or not anything is being drawn. The frozen position
also affected the seek bar, resume points and watch progress reported to the
server for as long as a PiP window was open. (UR-004, UR-040, UR-041 →
DR-265, DR-266)
## v0.11.4
### 🐛 Fixes
+9 -1
View File
@@ -17,11 +17,17 @@ row can be re-checked or disputed:
## Present since the first release
Fifteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and two months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them.
DR-265 is the clearest example of the "present since" / "reachable since" gap
this file warns about: the `!isPlaying` gate has been there since the first
commit, but nothing paused the document underneath a playing `<video>` until PiP
started working on the HTML5 path in v0.5.3. Defective for ~9 weeks, hittable
for ~2.
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|---|---|---|---|---|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
@@ -40,6 +46,7 @@ went unexercised until a later feature leaned on them.
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | **v0.11.5** | ~9 weeks | pickaxe |
### Why they took so long to surface
@@ -86,6 +93,7 @@ silently correct an out-of-range index — which is exactly why it was reported
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
| Recently Added trusted the server to group new tracks — `GroupItems=true` only groups a track whose parent chain resolves a `MusicAlbum`, and older servers ignore it | v0.5.1 | **v0.11.4** | feature (the v0.5.1 fix for the same symptom) |
| PiP and the background-audio handoff both armable, decided by one `isInPictureInPictureMode` sample (DR-266) | v0.5.3 | **v0.11.5** | feature (PiP on the HTML5 path, beside a toggle that had shipped in v0.0.16) |
Three of these are worth separating out, because the defect is not a mistake in
the code so much as **plumbing that was built and never connected**:
+7 -3
View File
@@ -458,6 +458,8 @@ Internal architecture, components, and application logic.
| DR-262 | The A-Z jump strip is bounded by the **scroller it lives in**, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is* the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded *inside* BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all | UI | UR-007 | Done |
| DR-263 | Autoplay crosses the **season boundary**. `fetch_next_episode_for_item` listed the episodes of the current season and stopped dead at the last one, so the end of a season produced `AutoplayDecision::Stop`. On the Android background-audio handoff (UR-040) that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it — the same end that mid-season advances through in the backend. The lookup now walks the series' seasons, sorted client-side by index number because the offline repository ignores `sort_by`, and takes the first episode of the next season that has any, skipping empty ones. Specials are never rolled *into*: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would land. The lookup sits below the sleep-timer gate in `on_playback_ended`, so a timer set to end-of-episode or a remaining-episode count still stops at the boundary rather than being carried past it | Player | UR-023, UR-040 | Done |
| DR-264 | The episode a viewer *just finished* is no longer offered as the one they are up to. Nothing records completion locally: the stop report writes a position through `storage_update_playback_progress` (which never sets `is_played`), and the cache mirror carried the server's flag not at all — so on a cache hit every episode read back as unwatched. Leaving the player with Back reloads the series page within a second of the stop report, inside the window where Jellyfin's Next Up still names the episode that just ended, and `pick_current_episode` handed it straight back: the season view kept the yellow ring and the "Up next" badge on the episode the viewer had just watched, and scrolled to it. Two halves. (a) `is_finished` — the played flag **or** a position at or past `MAX_PROGRESS_FRACTION` of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress — replaces the bare `is_played` in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate: the server is one stop-report behind for a moment, the local position is not. (b) `OfflineRepository::mirror_user_data` carries `is_played` alongside the favourite flag and the position, under the same `pending_sync = 0` conflict rule, so watched state survives a cache write instead of being dropped — that flag was previously written by nothing but an explicit local toggle | Repository | UR-062 | Done |
| DR-265 | The player's position variable keeps advancing behind a picture-in-picture window. `VideoPlayer` tracks the absolute position in its own `currentTime` rather than reading `videoElement.currentTime` at the point of use — transcoded HLS resets the element to 0 on every segment rebuild, so only the running total is meaningful — and that variable had exactly one writer while playing: a `requestAnimationFrame` loop. RAF is driven by the document being rendered, and an Android activity behind a PiP window is paused, so the loop stops while the element plays on. The `timeupdate` handler that would have covered the gap was written as a fallback "for when RAF isn't running" and gated itself on `!isPlaying`, switching itself off at precisely the moment it was the only source left. `currentTime` therefore froze at the instant PiP was entered, and every consumer froze with it: the seek bar, the ten-second progress reports, the position mirrored into Rust through `html5Adapter`, and — the reported symptom — the background-audio handoff, which resumed the audio-only stream at the PiP-entry position while the picture carried on where it really was. The gate is now `shouldApplyTimeUpdate` and turns only on the things that genuinely own the position instead: an in-flight seek, a seek-bar drag, and an element whose `readyState` is below `HAVE_CURRENT_DATA` (which reads 0 and would rewind). Both writers producing the same derived value costs nothing — the element is the authority either way | Player | UR-004, UR-041 | Done |
| DR-266 | PiP and the background-audio handoff can no longer be armed at once, and neither can a single stale boolean end the picture. They are alternatives — one keeps the video on screen, the other throws it away — but exclusivity was enforced from one side only: arming the toggle called `setAutoEnterEnabled(false)`, while the PiP *button* stayed ungated and still worked, so pressing it left both live. What then decided between them was `isInPictureInPictureMode`, sampled once inside `MainActivity.onStop()` and passed to `background_action`. That sample is not reliable: there are orderings — the keyguard dismissing the window, the window being stashed, OEM variance in when `onPictureInPictureModeChanged(false)` lands relative to `onStop` — where the activity is stopped with a PiP window still on screen and the flag reads false. Backgrounding then meant "the app is gone" and handed a video the user was watching in the window off to audio-only. Two halves. (a) `enteringPictureInPicture` disarms background audio, because pressing PiP is an unambiguous request to keep the picture; both directions now go through one `BackgroundBehaviour` pair rather than two ad-hoc call sites. (b) `inPictureInPicture` accepts either witness — the native sample or the frontend's own latch over `jellytau-pip-entered`/`jellytau-pip-exited`. The latch cannot report a window that has closed, because both events reach the WebView through the same message queue in dispatch order, so a genuine exit is always known before the background signal that follows it. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can | Player | UR-040, UR-041 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -471,7 +473,7 @@ Internal architecture, components, and application logic.
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
@@ -507,8 +509,8 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 |
@@ -795,6 +797,8 @@ Internal architecture, components, and application logic.
| UT-243 | A track that names no album has no container to collapse into and stays a track, the same way a movie does | JA-016 | Done |
| UT-244 | Recently Added over-fetches before collapsing, so folding one 14-track import together does not leave the row nearly empty | JA-016 | Done |
| UT-245 | A `timeupdate` is applied while the video is playing — the case that froze the position behind a PiP window — and still yields to an in-flight seek, a seek-bar drag, and an element with no current data | DR-265 | Done |
| UT-246 | Opening a PiP window disarms background audio, and a background signal arriving with the native PiP flag false is still treated as PiP while the frontend's latch says the window is open — without resurrecting one it has already seen close | DR-266 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.11.4",
"version": "0.11.5",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+1 -1
View File
@@ -8,7 +8,7 @@
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.11.4
pkgver=0.11.5
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
+1 -1
View File
@@ -2181,7 +2181,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.11.4"
version = "0.11.5"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.11.4"
version = "0.11.5"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.11.4",
"version": "0.11.5",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+55 -14
View File
@@ -80,8 +80,13 @@
shouldExitBackgroundAudio,
shouldResumeOnForeground,
planHandoffReturn,
setBackgroundAudioArmed,
enteringPictureInPicture,
inPictureInPicture,
type BackgroundAudioState,
type BackgroundBehaviour,
} from "./backgroundAudioHandoff";
import { shouldApplyTimeUpdate } from "./timeTracking";
import { createLogger } from "$lib/utils/logger";
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
@@ -1350,14 +1355,27 @@
}
}
// Fallback: Update time on timeupdate event (for when RAF isn't running)
// Second position source, alongside the RAF loop. It used to exclude itself
// whenever the video was playing, on the theory that RAF had it covered --
// but RAF only runs while the document is rendered, and an Android activity
// behind a PiP window is paused. `currentTime` then froze at the moment PiP
// was entered while the element played on, and every consumer of it froze
// too: the seek bar, the progress reports, the position mirrored into Rust,
// and -- the visible symptom -- the background-audio handoff, which resumed
// the audio-only stream back at the PiP-entry position. (DR-265)
function handleTimeUpdate() {
if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
const newCurrentTime = seekOffset + videoElement.currentTime;
if (videoElement.readyState >= 2) {
currentTime = newCurrentTime;
}
if (!videoElement) return;
if (
!shouldApplyTimeUpdate({
isPlaying,
isSeeking,
isDraggingSeekBar,
readyState: videoElement.readyState,
})
) {
return;
}
currentTime = seekOffset + videoElement.currentTime;
}
function handleLoadedMetadata() {
@@ -1833,6 +1851,11 @@
const pipSupported = isPipSupported();
function handlePictureInPicture() {
// Pressing PiP is an unambiguous request to keep the picture, so it disarms
// the behaviour that throws the picture away. Exclusivity was previously
// enforced only from the toggle's side (it suppressed *auto*-PiP), leaving
// this button able to arm both at once. (DR-266)
applyBackgroundBehaviour(enteringPictureInPicture(backgroundBehaviour()));
enterPip();
}
@@ -1855,16 +1878,26 @@
// what we stopped -- never something the user paused themselves.
let pausedByBackgrounding = false;
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
/** The pair of background behaviours as they currently stand. */
function backgroundBehaviour(): BackgroundBehaviour {
return setBackgroundAudioArmed(backgroundAudioOn);
}
/**
* Push a background-behaviour pair to both natives, so exactly one is armed.
*/
function applyBackgroundBehaviour(next: BackgroundBehaviour) {
backgroundAudioOn = next.backgroundAudioArmed;
log.debug("Background-audio toggle ->", backgroundAudioOn);
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
// so exactly one background behavior is active.
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
if (!armed) {
const armed = setBackgroundAudioEnabled(next.backgroundAudioArmed);
if (!armed && next.backgroundAudioArmed) {
log.warn("Background audio NOT armed natively (no bridge)");
}
setAutoEnterEnabled(!backgroundAudioOn);
setAutoEnterEnabled(next.autoPipEnabled);
}
function toggleBackgroundAudio() {
applyBackgroundBehaviour(setBackgroundAudioArmed(!backgroundAudioOn));
}
// App went to background/locked while background-audio is armed: hand off to
@@ -1879,7 +1912,15 @@
try {
action = await commands.playerBackgroundAction(
signal.backgroundAudioArmed,
signal.inPictureInPicture,
// Not `signal.inPictureInPicture` alone. That is one sample of
// `isInPictureInPictureMode`, taken inside onStop(); there are
// orderings -- the keyguard dismissing the window, the window being
// stashed, OEM variance in when onPictureInPictureModeChanged(false)
// lands -- where it reads false with the window still on screen, and
// the video the user is watching is handed off to audio. `isInPip` is
// a latch over the pip-entered/exited events, which arrive on the same
// queue ahead of this one. (DR-266)
inPictureInPicture(signal.inPictureInPicture, isInPip),
);
} catch (e) {
// Never leave playback in an undefined state because a decision call
@@ -7,6 +7,9 @@ import {
shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState,
setBackgroundAudioArmed,
enteringPictureInPicture,
inPictureInPicture,
} from "./backgroundAudioHandoff";
// TRACES: UR-040 | DR-052 | UT-060
@@ -137,3 +140,69 @@ describe("backgroundAudioHandoff", () => {
});
});
});
/**
* TRACES: UT-246 | DR-266
*/
describe("background behaviour exclusivity", () => {
describe("setBackgroundAudioArmed", () => {
it("disables auto-PiP when background audio is armed", () => {
expect(setBackgroundAudioArmed(true)).toEqual({
backgroundAudioArmed: true,
autoPipEnabled: false,
});
});
it("restores auto-PiP when background audio is disarmed", () => {
expect(setBackgroundAudioArmed(false)).toEqual({
backgroundAudioArmed: false,
autoPipEnabled: true,
});
});
});
describe("enteringPictureInPicture", () => {
it("disarms background audio when the user opens a PiP window", () => {
// THE REPORTED BUG, half one. Exclusivity was enforced in one direction
// only: arming the toggle suppressed auto-PiP, but the PiP *button* was
// still offered and still worked, leaving both behaviours live at once.
// A single stray background signal then handed a video the user was
// watching in a PiP window off to audio-only.
expect(
enteringPictureInPicture({ backgroundAudioArmed: true, autoPipEnabled: false }),
).toEqual({ backgroundAudioArmed: false, autoPipEnabled: true });
});
it("leaves an already-exclusive state alone", () => {
const state = { backgroundAudioArmed: false, autoPipEnabled: true };
expect(enteringPictureInPicture(state)).toEqual(state);
});
});
describe("inPictureInPicture", () => {
it("trusts the native flag when the two agree", () => {
expect(inPictureInPicture(true, true)).toBe(true);
expect(inPictureInPicture(false, false)).toBe(false);
});
it("treats a live PiP window as PiP even when the native flag says otherwise", () => {
// THE REPORTED BUG, half two. `isInPictureInPictureMode` is sampled once,
// inside onStop(). There are orderings -- the keyguard dismissing the
// window, the window being stashed, OEM variance in whether
// onPictureInPictureModeChanged(false) lands first -- where the activity
// is stopped with a PiP window still on screen and that single boolean
// reads false. Backgrounding then means "the app is gone" and the video
// the user is watching is handed off to audio.
expect(inPictureInPicture(false, true)).toBe(true);
});
it("does not resurrect a window the frontend has already seen close", () => {
// jellytau-pip-exited and jellytau-background are both posted to the same
// WebView message queue, in that order, so a genuine exit is always known
// by the time the background signal is handled. Leaving playback running
// here would be the opposite defect: audio continuing after the user
// closed the window and left the app.
expect(inPictureInPicture(false, false)).toBe(false);
});
});
});
@@ -121,3 +121,61 @@ export function planHandoffReturn(opts: {
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
};
}
/**
* Which of the two mutually exclusive background behaviours is armed.
*
* TRACES: UR-040, UR-041 | DR-266 | UT-246
*
* Backgrounding the app can either shrink the video into a picture-in-picture
* window (UR-041) or hand its audio off to the native player and drop the
* picture (UR-040). They are alternatives the first keeps the video on
* screen, the second throws it away so at most one may ever be armed.
*/
export interface BackgroundBehaviour {
/** The per-player background-audio toggle (UR-040). */
backgroundAudioArmed: boolean;
/** Whether leaving the app auto-enters PiP (UR-041). */
autoPipEnabled: boolean;
}
/** Arming/disarming the background-audio toggle flips auto-PiP the other way. */
export function setBackgroundAudioArmed(armed: boolean): BackgroundBehaviour {
return { backgroundAudioArmed: armed, autoPipEnabled: !armed };
}
/**
* The user has asked for a PiP window, by pressing the button rather than by
* leaving the app.
*
* Exclusivity used to be enforced from one side only arming the toggle
* suppressed auto-PiP while the PiP button stayed live and ungated. Pressing
* it left both behaviours armed, and the video was then one stray background
* signal away from being handed off to audio-only while the user was watching
* it in the window. Pressing PiP is an unambiguous request to keep the picture,
* so it disarms the behaviour that throws the picture away.
*/
export function enteringPictureInPicture(_current: BackgroundBehaviour): BackgroundBehaviour {
return setBackgroundAudioArmed(false);
}
/**
* Whether the app is in a picture-in-picture window, for the purpose of
* deciding what backgrounding means.
*
* TRACES: UR-040, UR-041 | DR-266 | UT-246
*
* @param nativeFlag the Activity's `isInPictureInPictureMode`, sampled inside
* `onStop()`
* @param sawPipEntered whether the frontend has seen `jellytau-pip-entered`
* without a matching `jellytau-pip-exited`
*/
export function inPictureInPicture(nativeFlag: boolean, sawPipEntered: boolean): boolean {
// Either witness is enough. The native flag is a single sample taken inside
// onStop(); the frontend's is a latch, set by `jellytau-pip-entered` and
// cleared by `jellytau-pip-exited`. Both events reach the WebView through the
// same message queue in dispatch order, so a genuine exit is always known
// before the background signal that follows it — the latch can report a
// window that is still open, never one that has closed.
return nativeFlag || sawPipEntered;
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { shouldApplyTimeUpdate } from "./timeTracking";
/**
* TRACES: UT-245 | DR-265
*/
describe("shouldApplyTimeUpdate", () => {
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
it("applies the update while the video is PLAYING", () => {
// THE REPORTED BUG. `timeupdate` was the only position source that still
// fires once requestAnimationFrame stops -- which is exactly what happens
// when the activity is paused behind a picture-in-picture window. Gating it
// on `!isPlaying` disabled it precisely when it was the only thing left,
// so the component's `currentTime` froze at the moment PiP was entered
// while the element played on. The background-audio handoff then resumed
// the audio-only stream at that frozen position.
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
});
it("still applies the update while paused", () => {
// The case it always handled: RAF is stopped, timeupdate carries the seek.
expect(shouldApplyTimeUpdate(base)).toBe(true);
});
it("yields to an in-flight seek", () => {
// A seek owns the position until it settles; a stale element read landing
// mid-seek is what makes a scrubbed video snap back.
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
});
it("yields while the user is dragging the seek bar", () => {
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
false,
);
});
it("ignores an element with no usable data yet", () => {
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Pure helpers for keeping the player's position variable honest.
*
* TRACES: UR-004, UR-041 | DR-265 | UT-245
*
* `VideoPlayer.svelte` tracks the absolute playback position in its own
* `currentTime` variable rather than reading `videoElement.currentTime` at the
* point of use transcoded HLS resets the element to 0 on every segment
* rebuild, so only the component's running total is meaningful. Everything
* downstream reads that variable: the seek bar, the progress reports, the
* position mirrored into Rust, and the background-audio handoff.
*
* Which makes "who is allowed to write it" a correctness question, not a
* rendering detail hence a pure module with tests rather than a condition
* buried in an event handler.
*/
export interface TimeUpdateGate {
/**
* Deliberately does NOT gate the update, and is accepted only to say so.
*
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
* document being rendered, and an Android activity behind a picture-in-picture
* window is paused: the loop stops while the element plays on, and the one
* remaining position source had switched itself off. Both writing the same
* derived value costs nothing the element is the authority either way.
*/
isPlaying?: boolean;
isSeeking: boolean;
isDraggingSeekBar: boolean;
readyState: number;
}
/**
* Whether a `timeupdate` event may write the component's position.
*
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
* player.
*/
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
// An in-flight seek or a drag owns the position until it settles, and an
// element with no current data reads 0, which would rewind it.
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
}