Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a04a6fad0 | ||
|
|
c0399e4ebd |
@@ -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
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
Generated
+1
-1
@@ -2181,7 +2181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.11.4"
|
||||
version = "0.11.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user