Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a04a6fad0 | ||
|
|
c0399e4ebd | ||
|
|
66e7889030 | ||
|
|
d25f6be697 | ||
|
|
079153d9d5 | ||
|
|
1ba836928f | ||
|
|
bbccc8567c |
@@ -9,6 +9,77 @@ 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
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
[docs/defect-windows.md](docs/defect-windows.md).
|
[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
|
||||||
|
|
||||||
|
- **A newly-added album shows up as one album, not as fourteen songs.**
|
||||||
|
Importing an album filled the whole Recently Added row with that one album,
|
||||||
|
one card per track, burying everything else added that week. The app does ask
|
||||||
|
the server to group new tracks under their album, but the server only manages
|
||||||
|
it for a track whose folder structure actually resolves an album — and older
|
||||||
|
servers ignore the request altogether. The row is now grouped here as well, so
|
||||||
|
what it shows is a property of the app rather than of the server it is talking
|
||||||
|
to: one card per album, opening the album, keeping its artwork and artists, and
|
||||||
|
standing where the first of its tracks stood so the newest is still first.
|
||||||
|
Standalone tracks and movies are unaffected. (UR-024 → JA-016)
|
||||||
|
|
||||||
|
- **A binge plays on past the end of a season.** Autoplay only ever looked
|
||||||
|
inside the current season, so the last episode of one was the end of the line:
|
||||||
|
with the screen locked and background audio playing, that is felt as playback
|
||||||
|
simply stopping mid-binge, with no visible player to start it again. It now
|
||||||
|
crosses into the first episode of the next season that has any, skipping empty
|
||||||
|
seasons and never wandering into Specials. A sleep timer set to end-of-episode
|
||||||
|
still stops at the boundary — crossing it is autoplay's decision, not the
|
||||||
|
timer's. (UR-023, UR-040 → DR-263)
|
||||||
|
|
||||||
|
- **The episode you just finished is no longer offered as the one to play next.**
|
||||||
|
Watch an episode to the end, press Back, and the season list still ringed that
|
||||||
|
same episode as "Up next" and scrolled to it. Nothing recorded the completion
|
||||||
|
on the device — progress writes saved a position but never marked an episode
|
||||||
|
watched, and cached server data dropped the watched flag on the way in — while
|
||||||
|
the server's own "next up" answer is briefly one report behind and named the
|
||||||
|
episode that had just ended. An episode is now counted as finished either when
|
||||||
|
the server says so or when its position is past the same 95% mark that already
|
||||||
|
disqualifies it from "continue watching", and the server's answer is checked
|
||||||
|
against that before it is accepted. The highlight, the badge, the auto-scroll
|
||||||
|
and which season opens expanded all follow the episode that really is next —
|
||||||
|
and the checkmarks in the season list stay put. (UR-025, UR-062 → DR-264)
|
||||||
|
|
||||||
|
### 🛠 Development
|
||||||
|
|
||||||
|
- **A debug build installs alongside a release build again.** `bun run
|
||||||
|
android:dev` produced an APK carrying the release application id, so installing
|
||||||
|
it over a real release build failed outright and the obvious way out was to
|
||||||
|
uninstall the release app and lose its data. The Tauri CLI rewrites the debug
|
||||||
|
section of the generated Gradle file to inject its own settings, and that
|
||||||
|
rewrite dropped the suffix that keeps the two apps apart; the suffix has moved
|
||||||
|
somewhere the rewrite does not reach, and the build now asserts the id the APK
|
||||||
|
actually carries rather than assuming it.
|
||||||
|
|
||||||
## v0.11.3
|
## v0.11.3
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|||||||
+10
-1
@@ -17,11 +17,17 @@ row can be re-checked or disputed:
|
|||||||
|
|
||||||
## Present since the first release
|
## 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.
|
shipped for between two weeks and two months before anyone hit them.
|
||||||
That is the dominant pattern here: not regressions, but original assumptions that
|
That is the dominant pattern here: not regressions, but original assumptions that
|
||||||
went unexercised until a later feature leaned on them.
|
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 |
|
| 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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| 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
|
### Why they took so long to surface
|
||||||
|
|
||||||
@@ -85,6 +92,8 @@ silently correct an out-of-range index — which is exactly why it was reported
|
|||||||
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||||
| 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) |
|
| 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) |
|
| 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
|
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**:
|
the code so much as **plumbing that was built and never connected**:
|
||||||
|
|||||||
+17
-4
@@ -456,6 +456,10 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
|
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
|
||||||
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
|
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
|
||||||
| 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-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 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -469,7 +473,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-001 | IR-001, IR-002 | - |
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
| 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-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-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-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 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
|
||||||
@@ -488,7 +492,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||||
| UR-022 | IR-017 | DR-025 |
|
| UR-022 | IR-017 | DR-025 |
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
|
||||||
| UR-024 | IR-010 | DR-027 |
|
| UR-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
@@ -505,8 +509,8 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| 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 |
|
| 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 |
|
| 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-042 | IR-009, IR-014 | DR-054 |
|
||||||
| UR-043 | IR-027 | DR-055 |
|
| UR-043 | IR-027 | DR-055 |
|
||||||
| UR-044 | - | DR-056 |
|
| UR-044 | - | DR-056 |
|
||||||
@@ -785,7 +789,16 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
|
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
|
||||||
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
|
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
|
||||||
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
|
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
|
||||||
|
| UT-238 | The last episode of a season rolls over into the first of the next, skips an empty season on the way, and still stops there when the sleep timer says so | DR-263 | Done |
|
||||||
|
| UT-239 | An episode watched to the end is not the current episode: not when the server's Next Up still names it (the stale answer is skipped in favour of the one after it), and not offline, where it counts as watched in the furthest-watched scan | DR-264 | Done |
|
||||||
|
| UT-240 | Caching a server result mirrors its played flag locally — as synced, never invented for an item that carries no user data, and never over an unsynced local toggle | DR-264 | Done |
|
||||||
|
| UT-241 | A newly-imported album reads as one album card, not one card per song: three tracks sharing an album id collapse into a single `MusicAlbum` entry that opens the album, keeps its artwork and album artist, drops track-only detail (track number, duration, album link), and takes the position of the first of its tracks so recency order and the neighbouring movie survive | JA-016 | Done |
|
||||||
|
| UT-242 | When the server did group, its own album row wins — its overview and detail survive and the tracks it also returned add no second card for the same album | JA-016 | Done |
|
||||||
|
| 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
|
### Integration Tests
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|||||||
+7769
-6916
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.11.3",
|
"version": "0.11.5",
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||||
|
|
||||||
pkgname=jellytau
|
pkgname=jellytau
|
||||||
pkgver=0.11.3
|
pkgver=0.11.5
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A cross-platform Jellyfin client"
|
pkgdesc="A cross-platform Jellyfin client"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -128,6 +128,44 @@ else
|
|||||||
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
|
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The applicationId the APK actually carries — not the one build.gradle.kts asks
|
||||||
|
# for. `tauri android build` rewrites the debug `buildTypes` block in the
|
||||||
|
# generated gradle file to inject its keepDebugSymbols entries, and that rewrite
|
||||||
|
# used to drop `applicationIdSuffix` with it, silently producing a debug APK
|
||||||
|
# under the release applicationId. Installing that over a real release build
|
||||||
|
# fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE, whose only obvious remedy is
|
||||||
|
# uninstalling the release app and losing its data — so this fails the build
|
||||||
|
# instead. The suffix now lives outside the rewritten block (see
|
||||||
|
# src-tauri/android/app/build.gradle.kts); this checks that it survived.
|
||||||
|
assert_application_id() {
|
||||||
|
local variant="$1" expected="$2"
|
||||||
|
local metadata="src-tauri/gen/android/app/build/outputs/apk/universal/$variant/output-metadata.json"
|
||||||
|
|
||||||
|
[ -f "$metadata" ] || return 0
|
||||||
|
|
||||||
|
local actual
|
||||||
|
actual=$(sed -n 's/.*"applicationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$metadata" | head -1)
|
||||||
|
|
||||||
|
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ APK applicationId is '$actual', expected '$expected'."
|
||||||
|
echo " A build meant for the side-by-side slot came out under the"
|
||||||
|
echo " release applicationId; installing it would collide with a real"
|
||||||
|
echo " install. Check that the applicationIdSuffix at the bottom of"
|
||||||
|
echo " src-tauri/android/app/build.gradle.kts survived into"
|
||||||
|
echo " src-tauri/gen/android/app/build.gradle.kts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
|
assert_application_id debug "com.dtourolle.jellytau.debug"
|
||||||
|
elif [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
|
assert_application_id release "com.dtourolle.jellytau.debug"
|
||||||
|
else
|
||||||
|
assert_application_id release "com.dtourolle.jellytau"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ APK build complete!"
|
echo "✅ APK build complete!"
|
||||||
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
||||||
|
|||||||
Generated
+1
-1
@@ -2181,7 +2181,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.11.3"
|
version = "0.11.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
|||||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||||
# which `tauri dev` issues — ambiguous.
|
# which `tauri dev` issues — ambiguous.
|
||||||
default-run = "jellytau"
|
default-run = "jellytau"
|
||||||
version = "0.11.3"
|
version = "0.11.5"
|
||||||
description = "A cross-platform Jellyfin client"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ android {
|
|||||||
// fully-qualified class names Rust looks up over JNI, the manifest
|
// fully-qualified class names Rust looks up over JNI, the manifest
|
||||||
// <service> entry and the R8 keep rules are all unaffected. The
|
// <service> entry and the R8 keep rules are all unaffected. The
|
||||||
// FileProvider authority is already ${applicationId}-relative.
|
// FileProvider authority is already ${applicationId}-relative.
|
||||||
applicationIdSuffix = ".debug"
|
//
|
||||||
|
// The suffix itself is applied AFTER this block -- see the bottom of
|
||||||
|
// this file. It cannot live here.
|
||||||
versionNameSuffix = "-debug"
|
versionNameSuffix = "-debug"
|
||||||
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||||
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||||
@@ -127,6 +129,30 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The debug applicationId suffix, applied from OUTSIDE the `buildTypes` block.
|
||||||
|
//
|
||||||
|
// `tauri android build` rewrites the `getByName("debug")` block in the *generated*
|
||||||
|
// copy of this file (src-tauri/gen/android/app/build.gradle.kts) to inject its
|
||||||
|
// `jniLibs.keepDebugSymbols` entries -- you can see the damage in the generated
|
||||||
|
// file, where `packaging {` ends up with the first injected line welded onto it.
|
||||||
|
// That rewrite drops `applicationIdSuffix` and nothing else: `versionNameSuffix`
|
||||||
|
// and the manifest placeholders beside it survive. It happens after
|
||||||
|
// sync-android-sources.sh has copied this file into place and before Gradle
|
||||||
|
// configures, so no amount of syncing can beat it.
|
||||||
|
//
|
||||||
|
// The result was a debug APK whose applicationId was plain
|
||||||
|
// `com.dtourolle.jellytau`, colliding with a real release install:
|
||||||
|
// INSTALL_FAILED_UPDATE_INCOMPATIBLE, with the only obvious way out being to
|
||||||
|
// uninstall the release app and lose its data. The `sideBySideRelease` suffix in
|
||||||
|
// the *release* build type is untouched by the same rewrite, which is why that
|
||||||
|
// path kept working and this one did not.
|
||||||
|
//
|
||||||
|
// A top-level statement is not inside the block the rewriter looks for, so it
|
||||||
|
// survives. scripts/build-android.sh asserts the built applicationId afterwards,
|
||||||
|
// so a future CLI that reaches further fails the build instead of shipping a
|
||||||
|
// colliding APK.
|
||||||
|
android.buildTypes.getByName("debug").applicationIdSuffix = ".debug"
|
||||||
|
|
||||||
rust {
|
rust {
|
||||||
rootDirRel = "../../../"
|
rootDirRel = "../../../"
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-16
@@ -2251,6 +2251,12 @@ impl PlayerController {
|
|||||||
return Ok(Some((current_repo_item, next.clone())));
|
return Ok(Some((current_repo_item, next.clone())));
|
||||||
} else {
|
} else {
|
||||||
log::info!("[PlayerController] Current episode is the last in the season");
|
log::info!("[PlayerController] Current episode is the last in the season");
|
||||||
|
if let Some(next) = self
|
||||||
|
.first_episode_of_next_season(¤t_repo_item, repo)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(Some((current_repo_item, next)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -2266,6 +2272,97 @@ impl PlayerController {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first episode of the season after this one, if the series has one.
|
||||||
|
///
|
||||||
|
/// A season boundary is not the end of a series, and stopping there is felt
|
||||||
|
/// most sharply on the background-audio path (UR-040): the screen is locked,
|
||||||
|
/// nothing shows a "next" button, and playback simply stops mid-binge. Every
|
||||||
|
/// other autoplay entry point shares this lookup, so foreground video and
|
||||||
|
/// the Android native path cross the boundary too.
|
||||||
|
///
|
||||||
|
/// Lookup failures degrade to `None` rather than an error: the episode has
|
||||||
|
/// already finished, and the caller's only alternative is to stop anyway.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||||
|
async fn first_episode_of_next_season(
|
||||||
|
&self,
|
||||||
|
current: &crate::repository::types::MediaItem,
|
||||||
|
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||||
|
) -> Option<crate::repository::types::MediaItem> {
|
||||||
|
use crate::repository::types::GetItemsOptions;
|
||||||
|
|
||||||
|
let series_id = current.series_id.as_deref()?;
|
||||||
|
let season_id = current.season_id.as_deref()?;
|
||||||
|
|
||||||
|
let season_options = GetItemsOptions {
|
||||||
|
sort_by: Some("IndexNumber".to_string()),
|
||||||
|
sort_order: Some("Ascending".to_string()),
|
||||||
|
limit: Some(500),
|
||||||
|
include_item_types: Some(vec!["Season".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
|
||||||
|
Ok(result) => result.items,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[PlayerController] Season lookup failed for series {}: {}",
|
||||||
|
series_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Same client-side sort as the episode list: the offline repository
|
||||||
|
// ignores sort_by and orders by sort_name instead.
|
||||||
|
seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
|
||||||
|
|
||||||
|
let current_idx = seasons.iter().position(|s| s.id == season_id)?;
|
||||||
|
|
||||||
|
for season in &seasons[current_idx + 1..] {
|
||||||
|
// Never roll into Specials. Jellyfin numbers them 0, so they sort
|
||||||
|
// ahead of season 1 and are normally unreachable from here -- but a
|
||||||
|
// server that leaves the index unset sorts them last, right where
|
||||||
|
// this walk would otherwise land.
|
||||||
|
if season.index_number == Some(0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let episode_options = GetItemsOptions {
|
||||||
|
sort_by: Some("IndexNumber".to_string()),
|
||||||
|
sort_order: Some("Ascending".to_string()),
|
||||||
|
limit: Some(500),
|
||||||
|
include_item_types: Some(vec!["Episode".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
|
||||||
|
Ok(result) => result.items,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[PlayerController] Episode lookup failed for season {}: {}",
|
||||||
|
season.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||||
|
|
||||||
|
// An empty season is a gap in the series, not the end of it.
|
||||||
|
if let Some(first) = episodes.into_iter().next() {
|
||||||
|
log::info!(
|
||||||
|
"[PlayerController] Rolling over to {} of {}: {}",
|
||||||
|
first.name,
|
||||||
|
season.name,
|
||||||
|
first.id
|
||||||
|
);
|
||||||
|
return Some(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[PlayerController] No further season to roll over into");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Start autoplay countdown thread
|
/// Start autoplay countdown thread
|
||||||
pub fn start_autoplay_countdown(
|
pub fn start_autoplay_countdown(
|
||||||
&self,
|
&self,
|
||||||
@@ -3685,23 +3782,67 @@ mod tests {
|
|||||||
/// lookup tests. Only `get_item` and `get_items` are used by
|
/// lookup tests. Only `get_item` and `get_items` are used by
|
||||||
/// `fetch_next_episode_for_item`; everything else is unreachable.
|
/// `fetch_next_episode_for_item`; everything else is unreachable.
|
||||||
struct MockEpisodeRepo {
|
struct MockEpisodeRepo {
|
||||||
episodes: Vec<repo_types::MediaItem>,
|
/// Seasons in the order the series lists them, each with its episodes.
|
||||||
|
seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockEpisodeRepo {
|
impl MockEpisodeRepo {
|
||||||
|
/// A one-season series, whose episodes keep the historical `ep{n}` ids.
|
||||||
fn season(count: usize) -> Self {
|
fn season(count: usize) -> Self {
|
||||||
let episodes = (1..=count)
|
Self::series(&[count])
|
||||||
.map(|i| {
|
}
|
||||||
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
|
|
||||||
item.name = format!("Episode {}", i);
|
/// A series whose seasons hold the given episode counts. Season 1 keeps
|
||||||
item
|
/// the `ep{n}` ids the single-season tests use; later seasons get
|
||||||
|
/// `s{season}e{n}` so a rollover assertion names the season it landed in.
|
||||||
|
fn series(counts: &[usize]) -> Self {
|
||||||
|
let seasons = counts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(s, count)| {
|
||||||
|
let season_number = s as i32 + 1;
|
||||||
|
let episodes = (1..=*count)
|
||||||
|
.map(|i| {
|
||||||
|
let id = if season_number == 1 {
|
||||||
|
format!("ep{}", i)
|
||||||
|
} else {
|
||||||
|
format!("s{}e{}", season_number, i)
|
||||||
|
};
|
||||||
|
make_repo_episode_in(season_number, &id, i as i32)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(make_repo_season(season_number), episodes)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self { episodes }
|
Self { seasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
|
||||||
|
self.seasons.iter().flat_map(|(_, eps)| eps.iter())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_repo_season(index: i32) -> repo_types::MediaItem {
|
||||||
|
repo_types::MediaItem {
|
||||||
|
id: format!("season{}", index),
|
||||||
|
name: format!("Season {}", index),
|
||||||
|
item_type: "Season".to_string(),
|
||||||
|
kind: crate::domain::MediaKind::Season,
|
||||||
|
is_folder: true,
|
||||||
|
parent_id: Some("series1".to_string()),
|
||||||
|
index_number: Some(index),
|
||||||
|
season_id: None,
|
||||||
|
season_name: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
..make_repo_episode(&format!("season{}", index), index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
|
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
|
||||||
|
make_repo_episode_in(1, id, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
|
||||||
repo_types::MediaItem {
|
repo_types::MediaItem {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: format!("Episode {}", index),
|
name: format!("Episode {}", index),
|
||||||
@@ -3709,7 +3850,7 @@ mod tests {
|
|||||||
kind: crate::domain::MediaKind::Episode,
|
kind: crate::domain::MediaKind::Episode,
|
||||||
is_folder: false,
|
is_folder: false,
|
||||||
server_id: "server".to_string(),
|
server_id: "server".to_string(),
|
||||||
parent_id: Some("season1".to_string()),
|
parent_id: Some(format!("season{}", season_number)),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
overview: None,
|
overview: None,
|
||||||
genres: None,
|
genres: None,
|
||||||
@@ -3731,9 +3872,9 @@ mod tests {
|
|||||||
index_number: Some(index),
|
index_number: Some(index),
|
||||||
series_id: Some("series1".to_string()),
|
series_id: Some("series1".to_string()),
|
||||||
series_name: Some("Test Series".to_string()),
|
series_name: Some("Test Series".to_string()),
|
||||||
season_id: Some("season1".to_string()),
|
season_id: Some(format!("season{}", season_number)),
|
||||||
season_name: Some("Season 1".to_string()),
|
season_name: Some(format!("Season {}", season_number)),
|
||||||
parent_index_number: Some(1),
|
parent_index_number: Some(season_number),
|
||||||
user_data: None,
|
user_data: None,
|
||||||
media_streams: None,
|
media_streams: None,
|
||||||
media_sources: None,
|
media_sources: None,
|
||||||
@@ -3751,18 +3892,29 @@ mod tests {
|
|||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
_options: Option<repo_types::GetItemsOptions>,
|
_options: Option<repo_types::GetItemsOptions>,
|
||||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||||
assert_eq!(parent_id, "season1", "episode lookup must query the season");
|
// The series lists its seasons; a season lists its episodes. Both
|
||||||
|
// are real lookups the autoplay path makes -- the second only once
|
||||||
|
// the first has told it which season comes next.
|
||||||
|
let items = if parent_id == "series1" {
|
||||||
|
self.seasons.iter().map(|(s, _)| s.clone()).collect()
|
||||||
|
} else {
|
||||||
|
self.seasons
|
||||||
|
.iter()
|
||||||
|
.find(|(season, _)| season.id == parent_id)
|
||||||
|
.map(|(_, eps)| eps.clone())
|
||||||
|
.unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
|
||||||
|
};
|
||||||
|
let total_record_count = items.len();
|
||||||
Ok(repo_types::SearchResult {
|
Ok(repo_types::SearchResult {
|
||||||
items: self.episodes.clone(),
|
items,
|
||||||
total_record_count: self.episodes.len(),
|
total_record_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async fn get_item(
|
async fn get_item(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||||
self.episodes
|
self.all_episodes()
|
||||||
.iter()
|
|
||||||
.find(|e| e.id == item_id)
|
.find(|e| e.id == item_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or(repo_types::RepoError::NotFound {
|
.ok_or(repo_types::RepoError::NotFound {
|
||||||
@@ -4004,6 +4156,91 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A season boundary is not the end of the series. The lookup used to stop
|
||||||
|
/// dead at the last episode of a season, which on Android's background-audio
|
||||||
|
/// path is felt as playback simply pausing at the end of an episode with the
|
||||||
|
/// screen locked and nothing to un-pause it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_next_episode_rolls_over_to_the_next_season() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
|
||||||
|
|
||||||
|
let decision = controller
|
||||||
|
.on_video_playback_ended("ep2", repo)
|
||||||
|
.await
|
||||||
|
.expect("decision should succeed");
|
||||||
|
|
||||||
|
match decision {
|
||||||
|
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||||
|
assert_eq!(
|
||||||
|
next_episode.id, "s2e1",
|
||||||
|
"the first episode of the next season follows the last of this one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A season with nothing in it is not the end of the series either.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023 | DR-263 | UT-238
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_next_episode_skips_an_empty_season() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
|
||||||
|
|
||||||
|
let decision = controller
|
||||||
|
.on_video_playback_ended("ep1", repo)
|
||||||
|
.await
|
||||||
|
.expect("decision should succeed");
|
||||||
|
|
||||||
|
match decision {
|
||||||
|
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||||
|
assert_eq!(next_episode.id, "s3e1");
|
||||||
|
}
|
||||||
|
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rollover must not out-rank the sleep timer: crossing a season
|
||||||
|
/// boundary is still a track boundary, and that is exactly where a timer set
|
||||||
|
/// to "end of episode" is supposed to stop.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-026 | DR-263 | UT-238
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sleep_timer_still_stops_at_a_season_boundary() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
|
||||||
|
controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
|
||||||
|
|
||||||
|
let episode = MediaItem {
|
||||||
|
transport: None,
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
duration: Some(180.0),
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: "http://example.com/ep1.mp3".to_string(),
|
||||||
|
jellyfin_item_id: "ep1".to_string(),
|
||||||
|
},
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
// Played through to the end -- a natural finish, not a stream cut short.
|
||||||
|
controller.seek(180.0).unwrap();
|
||||||
|
controller.take_end_reason();
|
||||||
|
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(decision, AutoplayDecision::Stop),
|
||||||
|
"the last episode the timer allows must stop, not roll into season 2 (got {:?})",
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Last episode of the season: no popup, stop.
|
/// Last episode of the season: no popup, stop.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_video_playback_ended_last_episode_stops() {
|
async fn test_video_playback_ended_last_episode_stops() {
|
||||||
|
|||||||
@@ -684,26 +684,33 @@ impl OfflineRepository {
|
|||||||
/// The position half is what makes cross-device resume work: the resume
|
/// The position half is what makes cross-device resume work: the resume
|
||||||
/// check reads this table alone, so before it was mirrored an item watched
|
/// check reads this table alone, so before it was mirrored an item watched
|
||||||
/// elsewhere resumed from whatever *this* device last saw, or not at all.
|
/// elsewhere resumed from whatever *this* device last saw, or not at all.
|
||||||
|
/// The played flag rides along for the same reason: nothing else writes it
|
||||||
|
/// but an explicit local toggle, so a cached episode list read every
|
||||||
|
/// episode back as unwatched — the list the season view ticks and the one
|
||||||
|
/// `pick_current_episode` reads to decide what is up next (DR-264).
|
||||||
///
|
///
|
||||||
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
|
/// TRACES: UR-025, UR-062, UR-069 | DR-114, DR-155, DR-264 | UT-102, UT-152, UT-240
|
||||||
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
|
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
|
||||||
let user_data = item.user_data.as_ref();
|
let user_data = item.user_data.as_ref();
|
||||||
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
|
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
|
||||||
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
|
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
|
||||||
|
let is_played = user_data.and_then(|ud| ud.is_played);
|
||||||
|
|
||||||
// Nothing the server actually told us about — do not invent a row.
|
// Nothing the server actually told us about — do not invent a row.
|
||||||
if is_favorite.is_none() && position_ticks.is_none() {
|
if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT INTO user_data
|
"INSERT INTO user_data
|
||||||
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
|
(user_id, item_id, is_favorite, playback_position_ticks, is_played,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, 0)
|
synced_at, pending_sync)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
|
||||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
|
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
|
||||||
playback_position_ticks = COALESCE(
|
playback_position_ticks = COALESCE(
|
||||||
excluded.playback_position_ticks, user_data.playback_position_ticks),
|
excluded.playback_position_ticks, user_data.playback_position_ticks),
|
||||||
|
is_played = COALESCE(excluded.is_played, user_data.is_played),
|
||||||
synced_at = excluded.synced_at
|
synced_at = excluded.synced_at
|
||||||
WHERE user_data.pending_sync = 0",
|
WHERE user_data.pending_sync = 0",
|
||||||
vec![
|
vec![
|
||||||
@@ -715,6 +722,9 @@ impl OfflineRepository {
|
|||||||
position_ticks
|
position_ticks
|
||||||
.map(QueryParam::Int64)
|
.map(QueryParam::Int64)
|
||||||
.unwrap_or(QueryParam::Null),
|
.unwrap_or(QueryParam::Null),
|
||||||
|
is_played
|
||||||
|
.map(|p| QueryParam::Int(if p { 1 } else { 0 }))
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
QueryParam::String(now.to_string()),
|
QueryParam::String(now.to_string()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -4786,6 +4796,98 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UT-240 — the server's *played* flag is mirrored locally, so an episode
|
||||||
|
/// watched anywhere is watched here.
|
||||||
|
///
|
||||||
|
/// The mirror carried only the favourite flag and the position, so
|
||||||
|
/// `user_data.is_played` was written by nothing but an explicit local
|
||||||
|
/// toggle: a cached episode list reported every episode as unwatched, which
|
||||||
|
/// is the list `pick_current_episode` reads to decide what is up next
|
||||||
|
/// (DR-264), and the list the season view ticks.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025, UR-062 | DR-264 | UT-240
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
|
||||||
|
use crate::storage::db_service::DatabaseService;
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(
|
||||||
|
db_service.clone(),
|
||||||
|
"test-server".to_string(),
|
||||||
|
"test-user".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let played_flag = |id: &'static str| {
|
||||||
|
let db = db_service.clone();
|
||||||
|
async move {
|
||||||
|
db.query_optional(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT is_played, pending_sync FROM user_data \
|
||||||
|
WHERE user_id = ? AND item_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String("test-user".to_string()),
|
||||||
|
QueryParam::String(id.to_string()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watched to the end on another client.
|
||||||
|
let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
|
||||||
|
watched.user_data = Some(UserData {
|
||||||
|
is_played: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// No user data at all — must not fabricate an "unwatched" record.
|
||||||
|
let untouched = create_test_item("ep-5", "No User Data", None);
|
||||||
|
|
||||||
|
repo.save_to_cache("parent-1", &[watched, untouched])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
played_flag("ep-4").await,
|
||||||
|
Some((Some(1), Some(0))),
|
||||||
|
"the server's played flag should be mirrored as synced"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
played_flag("ep-5").await,
|
||||||
|
None,
|
||||||
|
"an item without UserData should not get an invented played flag"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Marked unwatched here while the server was unreachable.
|
||||||
|
db_service
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"UPDATE user_data SET is_played = 0, pending_sync = 1 \
|
||||||
|
WHERE user_id = ? AND item_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String("test-user".to_string()),
|
||||||
|
QueryParam::String("ep-4".to_string()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
|
||||||
|
still_played.user_data = Some(UserData {
|
||||||
|
is_played: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
repo.save_to_cache("parent-1", &[still_played])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
played_flag("ep-4").await,
|
||||||
|
Some((Some(0), Some(1))),
|
||||||
|
"an unsynced local toggle must survive a cache write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// UT-152 — a server item carrying *only* a position (no favourite flag)
|
/// UT-152 — a server item carrying *only* a position (no favourite flag)
|
||||||
/// still gets mirrored.
|
/// still gets mirrored.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1306,6 +1306,117 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many rows to ask the server for, given how many the row will show.
|
||||||
|
///
|
||||||
|
/// Collapsing only ever shrinks a listing, so a request for exactly the number
|
||||||
|
/// of cards the row shows can come back as a handful after one freshly-ripped
|
||||||
|
/// album folds its tracks together. Over-fetch and truncate after collapsing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
|
||||||
|
fn latest_items_fetch_limit(limit: usize) -> usize {
|
||||||
|
limit.saturating_mul(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse newly-added *tracks* into the album they belong to.
|
||||||
|
///
|
||||||
|
/// `GroupItems=true` asks Jellyfin to do this server-side, but it only groups a
|
||||||
|
/// track whose parent chain actually resolves a `MusicAlbum`, and older servers
|
||||||
|
/// ignore the parameter outright — so "Recently Added" still filled up with one
|
||||||
|
/// card per song of a single import. Grouping again here makes the row's shape
|
||||||
|
/// a property of this app rather than of the server it is talking to.
|
||||||
|
///
|
||||||
|
/// Rules: a track collapses only when it names an `album_id` (without one there
|
||||||
|
/// is no album to open, so a standalone track stays a track); if the server did
|
||||||
|
/// return the album row itself, that row wins and its tracks are dropped; the
|
||||||
|
/// album takes the position of the first of its tracks, so recency order
|
||||||
|
/// survives. Everything else — movies, episodes, folders — passes through
|
||||||
|
/// untouched.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-242, UT-243
|
||||||
|
fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
// Albums the server already returned in their own right: their tracks are
|
||||||
|
// redundant, and the real row carries detail a stand-in cannot.
|
||||||
|
let server_albums: HashSet<String> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.kind == crate::domain::MediaKind::Album)
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut seen_albums: HashSet<String> = HashSet::new();
|
||||||
|
let mut collapsed = Vec::with_capacity(items.len());
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let album_id = match (&item.kind, &item.album_id) {
|
||||||
|
(crate::domain::MediaKind::Track, Some(id)) => id.clone(),
|
||||||
|
_ => {
|
||||||
|
collapsed.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
collapsed.push(album_from_track(&item, album_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
collapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the album card a collapsed group of tracks stands for.
|
||||||
|
///
|
||||||
|
/// The track's own artwork tag is reused: Jellyfin serves an item's primary
|
||||||
|
/// image by id and treats the tag as a cache key, and an embedded-art track
|
||||||
|
/// carries the album cover anyway.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
|
||||||
|
fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
|
||||||
|
MediaItem {
|
||||||
|
id: album_id,
|
||||||
|
name: track
|
||||||
|
.album_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||||
|
item_type: "MusicAlbum".to_string(),
|
||||||
|
kind: crate::domain::MediaKind::Album,
|
||||||
|
is_folder: true,
|
||||||
|
server_id: track.server_id.clone(),
|
||||||
|
parent_id: None,
|
||||||
|
library_id: track.library_id.clone(),
|
||||||
|
overview: None,
|
||||||
|
genres: track.genres.clone(),
|
||||||
|
production_year: track.production_year,
|
||||||
|
premiere_date: track.premiere_date.clone(),
|
||||||
|
community_rating: None,
|
||||||
|
official_rating: None,
|
||||||
|
// A track's duration says nothing about the album's, and its track
|
||||||
|
// number, album link and streams belong to the leaf alone.
|
||||||
|
runtime_ticks: None,
|
||||||
|
duration_ms: None,
|
||||||
|
primary_image_tag: track.primary_image_tag.clone(),
|
||||||
|
image_id: track.image_id.clone(),
|
||||||
|
backdrop_image_tags: track.backdrop_image_tags.clone(),
|
||||||
|
parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
|
||||||
|
album_id: None,
|
||||||
|
album_name: None,
|
||||||
|
album_artist: track.album_artist.clone(),
|
||||||
|
artists: track.artists.clone(),
|
||||||
|
artist_items: track.artist_items.clone(),
|
||||||
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
series_id: None,
|
||||||
|
series_name: None,
|
||||||
|
season_id: None,
|
||||||
|
season_name: None,
|
||||||
|
user_data: None,
|
||||||
|
media_streams: None,
|
||||||
|
media_sources: None,
|
||||||
|
people: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the Jellyfin endpoint for a Next Up listing.
|
/// Build the Jellyfin endpoint for a Next Up listing.
|
||||||
///
|
///
|
||||||
/// `EnableResumable=false` is the point of this query: the server default is
|
/// `EnableResumable=false` is the point of this query: the server default is
|
||||||
@@ -1755,18 +1866,34 @@ impl MediaRepository for OnlineRepository {
|
|||||||
Ok(media_item)
|
Ok(media_item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recently Added, one card per thing that was added.
|
||||||
|
///
|
||||||
|
/// The server is asked to group (`GroupItems=true`) *and* the answer is
|
||||||
|
/// grouped again here — see `collapse_tracks_into_albums` for why trusting
|
||||||
|
/// the server alone left the row full of one album's songs.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-244
|
||||||
async fn get_latest_items(
|
async fn get_latest_items(
|
||||||
&self,
|
&self,
|
||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
|
let limit_val = limit.unwrap_or(16);
|
||||||
|
let endpoint = build_latest_items_endpoint(
|
||||||
|
&self.user_id,
|
||||||
|
parent_id,
|
||||||
|
Some(latest_items_fetch_limit(limit_val)),
|
||||||
|
);
|
||||||
|
|
||||||
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
||||||
Ok(items
|
let items = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||||
.collect())
|
.collect();
|
||||||
|
|
||||||
|
let mut collapsed = collapse_tracks_into_albums(items);
|
||||||
|
collapsed.truncate(limit_val);
|
||||||
|
Ok(collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Continue Watching: the items this user has started and not finished.
|
/// Continue Watching: the items this user has started and not finished.
|
||||||
@@ -4122,6 +4249,135 @@ mod tests {
|
|||||||
assert!(endpoint.contains("Limit=16"));
|
assert!(endpoint.contains("Limit=16"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a `MediaItem` the way a real listing does — through the Jellyfin
|
||||||
|
/// payload — so the fixtures cannot drift from the parsed shape.
|
||||||
|
fn item_from_json(json: &str) -> MediaItem {
|
||||||
|
let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
|
||||||
|
parsed.into_media_item("srv".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
|
||||||
|
let album = match album_id {
|
||||||
|
Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
item_from_json(&format!(
|
||||||
|
r#"{{
|
||||||
|
"Id": "{id}",
|
||||||
|
"Name": "{name}",
|
||||||
|
"Type": "Audio",
|
||||||
|
{album}
|
||||||
|
"ImageTags": {{"Primary": "art-{id}"}},
|
||||||
|
"AlbumArtist": "Miles Davis",
|
||||||
|
"Artists": ["Miles Davis"],
|
||||||
|
"IndexNumber": 1,
|
||||||
|
"RunTimeTicks": 1000
|
||||||
|
}}"#
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newly-imported album must read as *one* new album, not one new song
|
||||||
|
/// per track — even when the server hands back the raw leaves despite
|
||||||
|
/// `GroupItems=true` (older servers, and libraries whose tracks resolve no
|
||||||
|
/// album parent, ignore it).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
|
||||||
|
#[test]
|
||||||
|
fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
|
||||||
|
let movie = item_from_json(
|
||||||
|
r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
|
||||||
|
);
|
||||||
|
let items = vec![
|
||||||
|
track("trk-1", "So What", Some("alb-1")),
|
||||||
|
track("trk-2", "Blue in Green", Some("alb-1")),
|
||||||
|
movie,
|
||||||
|
track("trk-3", "Flamenco Sketches", Some("alb-1")),
|
||||||
|
];
|
||||||
|
|
||||||
|
let collapsed = collapse_tracks_into_albums(items);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
collapsed.len(),
|
||||||
|
2,
|
||||||
|
"three tracks of one album plus a movie must read as two cards, got: {:?}",
|
||||||
|
collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
let album = &collapsed[0];
|
||||||
|
assert_eq!(album.id, "alb-1", "the card must open the album");
|
||||||
|
assert_eq!(album.name, "Kind of Blue");
|
||||||
|
assert_eq!(album.item_type, "MusicAlbum");
|
||||||
|
assert_eq!(album.kind, crate::domain::MediaKind::Album);
|
||||||
|
assert!(album.is_folder);
|
||||||
|
assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
|
||||||
|
assert!(album.image_id.is_some(), "album card needs artwork");
|
||||||
|
// Track-only detail must not ride along on a container.
|
||||||
|
assert!(album.index_number.is_none());
|
||||||
|
assert!(album.album_id.is_none());
|
||||||
|
assert!(album.runtime_ticks.is_none());
|
||||||
|
|
||||||
|
// The movie keeps its place after the album its tracks stood in front of.
|
||||||
|
assert_eq!(collapsed[1].id, "mov-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the server *did* group, its own album row wins — the tracks it also
|
||||||
|
/// returned must not add a second card for the same album.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-242
|
||||||
|
#[test]
|
||||||
|
fn test_collapse_prefers_the_album_row_the_server_returned() {
|
||||||
|
let album = item_from_json(
|
||||||
|
r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
|
||||||
|
"Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
|
||||||
|
);
|
||||||
|
let items = vec![
|
||||||
|
album,
|
||||||
|
track("trk-1", "So What", Some("alb-1")),
|
||||||
|
track("trk-2", "Blue in Green", Some("alb-1")),
|
||||||
|
];
|
||||||
|
|
||||||
|
let collapsed = collapse_tracks_into_albums(items);
|
||||||
|
|
||||||
|
assert_eq!(collapsed.len(), 1, "one album, one card");
|
||||||
|
assert_eq!(collapsed[0].id, "alb-1");
|
||||||
|
assert_eq!(
|
||||||
|
collapsed[0].overview.as_deref(),
|
||||||
|
Some("1959"),
|
||||||
|
"the server's own album row must survive, not a track-built stand-in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A track with no album has no container to collapse into, so it stays —
|
||||||
|
/// same reasoning that leaves movies alone.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-243
|
||||||
|
#[test]
|
||||||
|
fn test_collapse_leaves_a_standalone_track_alone() {
|
||||||
|
let items = vec![track("trk-1", "Field Recording", None)];
|
||||||
|
|
||||||
|
let collapsed = collapse_tracks_into_albums(items);
|
||||||
|
|
||||||
|
assert_eq!(collapsed.len(), 1);
|
||||||
|
assert_eq!(collapsed[0].id, "trk-1");
|
||||||
|
assert_eq!(collapsed[0].item_type, "Audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapsing shrinks the listing, so the request has to over-fetch: asking
|
||||||
|
/// for exactly 16 rows and then folding one 14-track album into them leaves
|
||||||
|
/// an almost empty "Recently Added".
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
|
||||||
|
#[test]
|
||||||
|
fn test_latest_items_over_fetches_before_collapsing() {
|
||||||
|
assert!(
|
||||||
|
latest_items_fetch_limit(16) > 16,
|
||||||
|
"must ask for more rows than the row shows"
|
||||||
|
);
|
||||||
|
let endpoint =
|
||||||
|
build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
|
||||||
|
assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
|
||||||
|
}
|
||||||
|
|
||||||
/// UT-190 — Next Up asks the server to leave resumable episodes out.
|
/// UT-190 — Next Up asks the server to leave resumable episodes out.
|
||||||
///
|
///
|
||||||
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
|
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
||||||
//! can be unit-tested without standing up a repository.
|
//! can be unit-tested without standing up a repository.
|
||||||
//!
|
//!
|
||||||
//! TRACES: UR-062 | DR-101
|
//! TRACES: UR-062 | DR-101, DR-264 | UT-239
|
||||||
|
|
||||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||||
|
|
||||||
@@ -83,6 +83,34 @@ fn is_played(item: &MediaItem) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Has the viewer reached the end of this episode?
|
||||||
|
///
|
||||||
|
/// The played *flag* is not enough. Nothing records completion locally — the
|
||||||
|
/// stop report writes a position, and the cache mirror carries the server's
|
||||||
|
/// flag only on the next refresh — so within seconds of an episode ending the
|
||||||
|
/// only local evidence that it is over is its position, parked at the very end
|
||||||
|
/// of its runtime. Leaving the player with Back reloads the series page inside
|
||||||
|
/// that window (DR-264).
|
||||||
|
fn is_finished(item: &MediaItem) -> bool {
|
||||||
|
if is_played(item) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(user_data) = item.user_data.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let position_ms = user_data
|
||||||
|
.playback_position_ms
|
||||||
|
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
||||||
|
.unwrap_or(0);
|
||||||
|
// Without a duration a position says nothing about how much is left.
|
||||||
|
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
position_ms as f64 / duration_ms as f64 >= MAX_PROGRESS_FRACTION
|
||||||
|
}
|
||||||
|
|
||||||
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||||
item.series_id.as_deref() == Some(series_id)
|
item.series_id.as_deref() == Some(series_id)
|
||||||
}
|
}
|
||||||
@@ -130,11 +158,20 @@ pub fn pick_current_episode(
|
|||||||
return Some(found.clone());
|
return Some(found.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Next Up for this series.
|
// 2. Next Up for this series — unless it names an episode we can already
|
||||||
if let Some(found) = next_up
|
// see is over. Next Up is the server's answer, and the server is one
|
||||||
.iter()
|
// stop-report behind for a moment after an episode ends; the local
|
||||||
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
// position is not, so a finished candidate is dropped rather than handed
|
||||||
{
|
// back as "up next" (DR-264).
|
||||||
|
if let Some(found) = next_up.iter().find(|e| {
|
||||||
|
if !(e.series_id.is_none() || belongs_to_series(e, series_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Judge it by the copy from `episodes` when there is one: that is the
|
||||||
|
// copy carrying the local user-data.
|
||||||
|
let local = episodes.iter().find(|listed| listed.id == e.id);
|
||||||
|
!is_finished(local.unwrap_or(e))
|
||||||
|
}) {
|
||||||
// Prefer the copy from `episodes` when we have one: it carries the
|
// Prefer the copy from `episodes` when we have one: it carries the
|
||||||
// user-data and images the list already fetched.
|
// user-data and images the list already fetched.
|
||||||
let matched = episodes.iter().find(|e| e.id == found.id);
|
let matched = episodes.iter().find(|e| e.id == found.id);
|
||||||
@@ -145,7 +182,7 @@ pub fn pick_current_episode(
|
|||||||
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
||||||
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
||||||
// where they stopped is the *last* thing they watched.
|
// where they stopped is the *last* thing they watched.
|
||||||
if let Some(furthest) = episodes.iter().rposition(is_played) {
|
if let Some(furthest) = episodes.iter().rposition(is_finished) {
|
||||||
if let Some(found) = episodes.get(furthest + 1) {
|
if let Some(found) = episodes.get(furthest + 1) {
|
||||||
return Some(found.clone());
|
return Some(found.clone());
|
||||||
}
|
}
|
||||||
@@ -153,7 +190,7 @@ pub fn pick_current_episode(
|
|||||||
|
|
||||||
// Nothing watched yet (or the furthest-watched episode is the finale):
|
// Nothing watched yet (or the furthest-watched episode is the finale):
|
||||||
// the first unwatched episode in series order.
|
// the first unwatched episode in series order.
|
||||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
if let Some(found) = episodes.iter().find(|e| !is_finished(e)) {
|
||||||
return Some(found.clone());
|
return Some(found.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +464,41 @@ mod tests {
|
|||||||
assert_eq!(current.id, "s2e1");
|
assert_eq!(current.id, "s2e1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The episode the viewer just finished must not still be "up next".
|
||||||
|
///
|
||||||
|
/// Leaving the player with Back reloads the series page within a second of
|
||||||
|
/// the stop report, and Jellyfin's Next Up can still name the episode that
|
||||||
|
/// just ended. Locally we know better: the position sits at the very end of
|
||||||
|
/// its runtime.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-264 | UT-239
|
||||||
|
#[test]
|
||||||
|
fn a_just_finished_episode_is_not_current_even_when_next_up_still_names_it() {
|
||||||
|
let mut eps = season(1, 5);
|
||||||
|
eps[0] = watched(eps[0].clone());
|
||||||
|
// Just finished: the position is at the end, the flag has not landed.
|
||||||
|
eps[1] = in_progress(eps[1].clone(), 0.99);
|
||||||
|
// The server has not caught up with the stop report.
|
||||||
|
let next_up = vec![episode("s1e2", 1, 2)];
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s1e3");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, offline: with no Next Up to lean on, an episode watched to the
|
||||||
|
/// end counts as watched when scanning for the furthest-watched one.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-264 | UT-239
|
||||||
|
#[test]
|
||||||
|
fn an_episode_watched_to_the_end_counts_as_watched_offline() {
|
||||||
|
let mut eps = season(1, 5);
|
||||||
|
eps[0] = watched(eps[0].clone());
|
||||||
|
eps[1] = in_progress(eps[1].clone(), 0.99);
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s1e3");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_never_watched_series_opens_on_its_premiere() {
|
fn a_never_watched_series_opens_on_its_premiere() {
|
||||||
let eps = [season(2, 3), season(1, 3)].concat();
|
let eps = [season(2, 3), season(1, 3)].concat();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.11.3",
|
"version": "0.11.5",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -80,8 +80,13 @@
|
|||||||
shouldExitBackgroundAudio,
|
shouldExitBackgroundAudio,
|
||||||
shouldResumeOnForeground,
|
shouldResumeOnForeground,
|
||||||
planHandoffReturn,
|
planHandoffReturn,
|
||||||
|
setBackgroundAudioArmed,
|
||||||
|
enteringPictureInPicture,
|
||||||
|
inPictureInPicture,
|
||||||
type BackgroundAudioState,
|
type BackgroundAudioState,
|
||||||
|
type BackgroundBehaviour,
|
||||||
} from "./backgroundAudioHandoff";
|
} from "./backgroundAudioHandoff";
|
||||||
|
import { shouldApplyTimeUpdate } from "./timeTracking";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
|
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() {
|
function handleTimeUpdate() {
|
||||||
if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
|
if (!videoElement) return;
|
||||||
const newCurrentTime = seekOffset + videoElement.currentTime;
|
if (
|
||||||
if (videoElement.readyState >= 2) {
|
!shouldApplyTimeUpdate({
|
||||||
currentTime = newCurrentTime;
|
isPlaying,
|
||||||
}
|
isSeeking,
|
||||||
|
isDraggingSeekBar,
|
||||||
|
readyState: videoElement.readyState,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
currentTime = seekOffset + videoElement.currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadedMetadata() {
|
function handleLoadedMetadata() {
|
||||||
@@ -1833,6 +1851,11 @@
|
|||||||
const pipSupported = isPipSupported();
|
const pipSupported = isPipSupported();
|
||||||
|
|
||||||
function handlePictureInPicture() {
|
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();
|
enterPip();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1855,16 +1878,26 @@
|
|||||||
// what we stopped -- never something the user paused themselves.
|
// what we stopped -- never something the user paused themselves.
|
||||||
let pausedByBackgrounding = false;
|
let pausedByBackgrounding = false;
|
||||||
|
|
||||||
function toggleBackgroundAudio() {
|
/** The pair of background behaviours as they currently stand. */
|
||||||
backgroundAudioOn = !backgroundAudioOn;
|
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);
|
log.debug("Background-audio toggle ->", backgroundAudioOn);
|
||||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
const armed = setBackgroundAudioEnabled(next.backgroundAudioArmed);
|
||||||
// so exactly one background behavior is active.
|
if (!armed && next.backgroundAudioArmed) {
|
||||||
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
|
||||||
if (!armed) {
|
|
||||||
log.warn("Background audio NOT armed natively (no bridge)");
|
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
|
// App went to background/locked while background-audio is armed: hand off to
|
||||||
@@ -1879,7 +1912,15 @@
|
|||||||
try {
|
try {
|
||||||
action = await commands.playerBackgroundAction(
|
action = await commands.playerBackgroundAction(
|
||||||
signal.backgroundAudioArmed,
|
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) {
|
} catch (e) {
|
||||||
// Never leave playback in an undefined state because a decision call
|
// Never leave playback in an undefined state because a decision call
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
shouldExitBackgroundAudio,
|
shouldExitBackgroundAudio,
|
||||||
shouldResumeOnForeground,
|
shouldResumeOnForeground,
|
||||||
type BackgroundAudioState,
|
type BackgroundAudioState,
|
||||||
|
setBackgroundAudioArmed,
|
||||||
|
enteringPictureInPicture,
|
||||||
|
inPictureInPicture,
|
||||||
} from "./backgroundAudioHandoff";
|
} from "./backgroundAudioHandoff";
|
||||||
|
|
||||||
// TRACES: UR-040 | DR-052 | UT-060
|
// 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),
|
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