Compare commits

...
6 Commits
Author SHA1 Message Date
dtourolle 73641e192c chore(release): 0.7.0
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 21m50s
Traceability Validation / Check Requirement Traces (push) Successful in 44s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m50s
Build & Release / Run Tests (push) Successful in 18m46s
Build & Release / Build Linux (push) Successful in 30m52s
Build & Release / Build Windows (push) Successful in 15m13s
Build & Release / Build Android (push) Successful in 31m53s
Build & Release / Create Release (push) Successful in 12s
Version bumped across package.json, tauri.conf.json and Cargo.toml (+ lock),
CHANGELOG entry written from the five commits in the range rather than from the
trace extractor's output — VideoPlayer.svelte alone carries dozens of TRACES, so
the generated draft named most of the app's requirements for a five-commit
release.

DR-188 is retargeted: it recorded the native-video default as waiting on the
background-audio handoff, which is now fixed (DR-196), so it records the
completed flip and the evidence for it instead.

Minor, not patch: the rendering path changes underneath every Android user.
2026-08-16 22:28:05 +02:00
dtourolle be907b4945 fix(home): stop Next Up repeating Continue Watching
Jellyfin's /Shows/NextUp defaults EnableResumable=true, which returns a
partially-watched episode as its own series' next up — precisely the
episode /Items/Resume already returns. Home's "Next Episode" row and the
TV landing's Next Up row therefore duplicated Continue Watching card for
card.

build_next_up_endpoint now sends EnableResumable=false, and because
servers predating that parameter ignore it, filterInProgressNextUpItems
also drops any next-up entry whose id appears in the resume list. It is
the mirror of DR-089 and sits beside it: presentation-layer de-duplication
over two lists the frontend already holds. The resume filter still reads
its frontier from the unfiltered Next Up list, so pruning in-progress
entries cannot resurrect a stale resume card.

The code changes were swept into 5e8efa25 by a concurrent `git add -A`;
this carries the remainder — DR-197 / JA-036 / UT-190..192, the
renumbering off the DR-196 collision that commit created, the regenerated
matrix, and the requirement-count guard.

TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191, UT-192
2026-08-16 22:18:06 +02:00
dtourolle 3b9a8ad695 test(player): pin the native-video default and the opt-out that must survive it
The default has moved four times, so the risk is not which way it points but
that a flip silently overrides people who chose. The previous reader was
getItem(KEY) === "true", which conflates "never chose" with "chose off" — under
it, flipping the default re-enables the native path for everyone who had
deliberately turned it off. The three cases are pinned separately so that
conflation cannot come back.
2026-08-16 22:16:39 +02:00
dtourolle ab95f5013d feat(player): make native Android video the default
The two defects that were holding the flip back are fixed and verified on a
device, which is the standard this default has been held to since DR-161 shipped
a verified sub-path over an unverified one:

  - returning from background audio restarts the renderer that is actually on
    screen, instead of only ever reloading the <video> element (DR-196)
  - the letterbox bars are painted, instead of retaining whatever was last in
    the framebuffer (DR-194)

Evidence: handoff to audio-only at 69:54 returning to video playing at 70:18,
and clean bars across playback, the control bar and a rotation round-trip.

An explicit stored choice still wins in both directions, so anyone who turned the
flag off keeps it off — hence the null check on the stored value rather than a
bare === "true", which would silently re-enable it for people who opted out.

The Settings copy no longer tells users to leave it off; it now describes the
toggle as the fallback to the built-in web player.

The flag keeps its "experimental" name because it remains a suppressor of Rust's
backend choice, never a promoter: turning it on cannot produce a native backend
where Rust says HTML5.
2026-08-16 22:14:43 +02:00
dtourolle 5e8efa252e fix(player): restart the native renderer when returning from background audio
With native video on, coming back from background audio left a black screen: a
play overlay pinned at 0:00, a seek bar at zero, and a play button that did
nothing. Nothing crashed — the process stayed up and the frontend kept logging —
the transition was simply dropped.

The two render paths resume by different means, and exitBackgroundAudioHandoff
only ever performed one of them. The webview <video> reloads off its stream URL:
an $effect watches it, reinitialises HLS or sets element.src, and canplay drives
the seek and play. ExoPlayer owns no element and nothing watches the URL on its
behalf — native playback is only ever started by an explicit player_play_item
plus adapter load, which the component issues once, from onMount. So reassigning
the URL restarted precisely nothing, and since player_exit_background_audio had
already stopped the handoff's audio player, the backend came back holding no item
at all. That is why the play button was inert: there was nothing loaded to play.

The return now re-issues that pair on the native path, in the same order as the
initial load, carrying the position the audio reached. Subtitle configurations are
reused from the ones resolved at mount — ExoPlayer sideloads them as
MediaItem.SubtitleConfigurations and cannot accept one after prepare().

Which path to take is decided by planHandoffReturn, a pure helper in
backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the
player. It also folds in shouldResumeOnForeground, so a pause taken on the
lockscreen during the handoff still wins over the snapshot captured on the way
out.

Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54,
return restored native video playing at 70:18. Previously the same sequence left
the player idle and black.

The requirements count pin in extract-traces.test.ts moves with the new DR-196.
2026-08-16 22:10:14 +02:00
dtourolle 1285908733 fix(android): paint the letterbox bars, so stale pixels stop surviving in them
Native video left debris in the padding around the video: the "previous frame"
flash on rotation, a ghost copy of the control bar stranded in the top bar, each
new clock digit drawn over the one before it (35:42 with the 1 still showing
through the 2), and the sleep/quality menus leaving their imprint after closing.
One cause under all of it — nothing painted those bars.

The window surface is opaque; the theme is not translucent and dumpsys window
shows no translucency flag. For an opaque surface HWUI deliberately does NOT
clear the damaged region before replaying a frame: it assumes the view hierarchy
covers every pixel it owns. Here that hierarchy is window background → video
TextureView → transparent WebView, and fitSurfaceToScreen sizes the TextureView
to the letterboxed video rect. So the bars were the window background's alone to
paint, and setTransparent(true) cleared it to TRANSPARENT — leaving them painted
by nobody, with whatever was last in the framebuffer surviving there.

The window background now stays opaque black while compositing. It cannot hide
the video: the TextureView is drawn on top of it, and the WebView's own
background is what lets the picture through.

Three previous attempts missed because they aimed at the window's rotation
animation and at TextureView frame-retention — two postOnAnimation hops, an
onSurfaceTextureUpdated reveal, then ROTATION_ANIMATION_JUMPCUT with
FLAG_FULLSCREEN to make it stick. The pixels were never the animation's, which is
also why the artefact reproduces standing still, with no rotation involved. Those
are removed. The alpha-hiding among them actively made things worse: it blanked
the one view that reliably paints its own rect. FLAG_FULLSCREEN goes too — it
fought edge-to-edge insets for no gain.

Verified on device (HONOR ROD2-W09, Android 16): reproduced with native video on
— ghost control bar in the top bar, doubled clock digit — then absent after the
fix across playback, the control bar and a rotation round-trip.

DR-194 is rewritten to record the real mechanism and marked Done.
2026-08-16 21:51:14 +02:00
21 changed files with 5517 additions and 5041 deletions
+44
View File
@@ -9,6 +9,50 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.7.0
### ✨ Changes
- **Native Android video is now the default.** Video decodes on the device's
hardware decoder instead of the built-in web player, which is easier on the
battery and lets picture-in-picture show the video rather than the app. The
default had been held back deliberately since the picture defects were fixed,
because returning from background audio left playback dead on that path; both
blockers below are fixed and verified on a device, which is the standard this
default has been held to since it last shipped early. The Settings toggle
remains, now as the fallback to the web player, and an explicit choice still
wins in both directions — anyone who turned it off keeps it off.
(UR-003, UR-004 → DR-188)
### 🐛 Fixes
- **The letterbox bars stop showing things that are no longer there.** With
native video on, the padding around the picture kept whatever had last been
drawn in it: the previous frame flashing on rotation, a ghost copy of the
control bar stranded at the top of the screen, each new clock digit drawn over
the one before it, and the sleep-timer and quality menus leaving their imprint
after closing. One cause under all of it — nothing painted those bars. The
window surface is opaque, and for an opaque surface Android's renderer skips
clearing the damaged region and assumes the view hierarchy covers every pixel;
the video view covers only the letterboxed rect, so the bars were the window
background's alone to paint, and enabling compositing had cleared that
background to transparent. Three earlier attempts missed because they aimed at
the window's rotation animation and at video-frame retention — which is also
why the artefact reproduced standing still, with no rotation involved.
(UR-003, UR-066 → DR-194)
- **Returning from background audio brings the picture back.** On the native
path, coming back from the lockscreen left a black screen: a play overlay
pinned at 0:00 and a play button that did nothing. Nothing had crashed — the
transition was simply dropped. The two render paths resume by different means,
and only one of them was performed: the web player reloads from its stream URL,
while the native player owns no element and nothing watches that URL on its
behalf, so it has to be handed the item again explicitly. It now is, at the
position the audio reached. (UR-040, UR-003 → DR-196)
- **Next Up stops repeating what Continue Watching already shows.** The same
episode could occupy both home rows at once. (UR-023 → DR-197)
## v0.6.0
### 🐛 Fixes
+10 -4
View File
@@ -171,6 +171,7 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
### 2.3 Development Requirements
@@ -346,10 +347,12 @@ Internal architecture, components, and application logic.
| DR-185 | The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute **no component has ever set, in any commit**. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes *itself* transparent on the native path, but it stacks *above* the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the *relationship* rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device | UI | UR-003, UR-004, UR-041 | Done |
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: returning from background audio is HTML5-only (DR-190), so on the native path playback simply stays dead. Shipping it would have repeated DR-161 exactly — a verified sub-path made default over an unverified one — so the default stays off and the flip is gated on DR-190 rather than on more confidence | UI | UR-003, UR-004, UR-041 | Blocked by DR-190 |
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
| DR-194 | The previous frame flashing on rotation. It reads as a TextureView artefact — the view retains its last frame, so between a rotation and `fitSurfaceToScreen()` landing that frame sits at the old size — and two fixes were built on that reading: revealing after two `postOnAnimation` hops, then revealing on `onSurfaceTextureUpdated`, which required owning the `SurfaceTextureListener` and handing ExoPlayer the Surface directly rather than via `setVideoTextureView`. **Neither stopped the flash.** The mechanism is the *window's* rotation animation: Android cross-fades a **screenshot of the old orientation**, that screenshot contains the old video frame at the old size, and no TextureView bookkeeping can reach it — nor can the app pre-empt the screenshot, since `onConfigurationChanged` fires after it is taken. The only lever is to stop the animation: `ROTATION_ANIMATION_JUMPCUT`. That was accepted and silently ignored at first, and the platform said why out loud — `VRI[MainActivity]: setLayoutParams: not fullscreen` — because the attribute is honoured only for a fullscreen window. `FLAG_FULLSCREEN` (deprecated for hiding system bars, which immersive mode does instead, but still what marks the window fullscreen for this decision) is therefore set alongside it, scoped to while native compositing is active so the rest of the app keeps its normal animation. The frame-arrival reveal is kept: it replaced a fixed-timeout guess with a real signal, and its timeout is required rather than defensive, since a resize while paused means no new frame is ever coming. **The flash is not confirmed fixed on device** — the forced-rotation harness (`settings put system user_rotation`) proved unreliable, and `screenrecord` fixes its canvas at start so a rotation inside a recording never changes frame dimensions, which defeated two attempts at measuring it | Android | UR-003, UR-066 | Needs device verification |
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
| DR-192 | Native video presents through a **TextureView**, not a SurfaceView. A SurfaceView renders on its own layer *outside* the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds | Android | UR-003, UR-004, UR-041 | Done |
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Proposed |
@@ -374,7 +377,7 @@ Internal architecture, components, and application logic.
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195 |
| 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 |
| 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 | - |
@@ -411,7 +414,7 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190 |
| 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 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
@@ -637,6 +640,9 @@ Internal architecture, components, and application logic.
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
| UT-190 | `build_next_up_endpoint` sends `EnableResumable=false` with the user and limit, and no `SeriesId` filter when none was requested | DR-197, JA-036 | Done |
| UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done |
| UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done |
### Integration Tests
+4961 -4835
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.6.0",
"version": "0.7.0",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
+3 -3
View File
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(185);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(327);
expect(defined.DR).toBe(187);
expect(defined.JA).toBe(36);
expect(defined.total).toBe(330);
});
});
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.6.0"
version = "0.7.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -191,37 +191,6 @@ class MainActivity : TauriActivity() {
super.onDestroy()
}
/**
* Rotation (and any other config change this Activity handles itself).
*
* Two things have to happen here rather than later, and both are about the
* *previous* video frame surviving the transition:
*
* - The video view is hidden until a new frame arrives. The equivalent call
* in `fitSurfaceToScreen` runs from the content view's layout listener,
* which is after the rotation by then the stale frame has been on screen
* for the whole transition.
* - The window's rotation animation is a **cross-fade of a screenshot** of
* the old orientation, and that screenshot contains the old video frame at
* the old size. No amount of TextureView bookkeeping can touch it, which is
* why hiding on frame-arrival alone did not stop the flash. `JUMPCUT` drops
* the cross-fade, so there is no old frame to fade through; it is set only
* while native compositing is active (see setTransparent) so the rest of
* the app keeps the normal animation.
*
* TRACES: UR-003, UR-066 | DR-194
*/
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
try {
if (com.dtourolle.jellytau.player.JellyTauPlayer.isInitialized()) {
com.dtourolle.jellytau.player.JellyTauPlayer.getInstance().hideUntilFreshFrame()
}
} catch (e: Exception) {
android.util.Log.w("MainActivity", "hideUntilFreshFrame on config change failed", e)
}
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: android.content.res.Configuration
@@ -401,45 +370,46 @@ class MainActivity : TauriActivity() {
@JavascriptInterface
fun setTransparent(transparent: Boolean) {
handler.post {
val color = if (transparent) {
android.graphics.Color.TRANSPARENT
} else {
android.graphics.Color.BLACK
}
mediaWebView?.setBackgroundColor(color)
// The WebView's window/surface must also stop painting opaque, or a
// hardware-accelerated WebView still composites its own background.
window.setBackgroundDrawable(
android.graphics.drawable.ColorDrawable(color)
mediaWebView?.setBackgroundColor(
if (transparent) {
android.graphics.Color.TRANSPARENT
} else {
android.graphics.Color.BLACK
}
)
// The WINDOW background stays OPAQUE — including while compositing.
// It is the only thing that paints the pixels the video does not
// cover, and clearing it was the whole defect.
//
// This window's surface is opaque: the theme is not translucent and
// `dumpsys window` shows no translucency flag on it. For an opaque
// surface HWUI deliberately does NOT clear the damaged region before
// replaying a frame — it assumes the view hierarchy paints every
// pixel it owns. That hierarchy is: window background, then the video
// TextureView, then this transparent WebView. `fitSurfaceToScreen`
// sizes the TextureView to the *letterboxed* video rect, so the bars
// around the video are painted by the window background and nothing
// else.
//
// Setting that background TRANSPARENT therefore left the bars painted
// by nobody, and stale framebuffer content simply survived in them:
// a whole ghost copy of the control bar stranded in the top bar, and
// each new clock digit composited over the one before it ("35:42"
// with the 1 still showing through the 2). The rotation flash is the
// same bug at full-screen scale — the pre-rotation image persisting
// in what became the new bars — which is why neither
// ROTATION_ANIMATION_JUMPCUT nor revealing on frame arrival ever
// touched it. Both were aimed at the window animation; the pixels
// were never the animation's.
//
// The WebView's own background, set above, is what lets the video
// through. An opaque window background cannot hide it: the
// TextureView is drawn on top of it, not under it.
//
// TRACES: UR-003, UR-066 | DR-194
window.setBackgroundDrawable(
android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)
)
// Drop the rotation cross-fade while a native video surface is
// composited behind the page. The animation fades a *screenshot* of
// the old orientation, which still holds the previous video frame at
// the old size — that is the "previous frame flashing in the black
// bars", and it lives in the window animation rather than in
// anything the TextureView owns. (DR-194)
val attrs = window.attributes
attrs.rotationAnimation = if (transparent) {
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
} else {
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_ROTATE
}
window.attributes = attrs
// `rotationAnimation` is honoured only for a **fullscreen** window —
// the platform says so out loud, logging
// "VRI[MainActivity]: setLayoutParams: not fullscreen" when the
// attribute is set on ours, and then animating normally regardless.
// Without this the JUMPCUT above is accepted and ignored, and the
// cross-fade keeps showing the old orientation's screenshot, stale
// video frame and all. FLAG_FULLSCREEN is deprecated for *hiding
// system bars* (immersive mode does that, on player entry), but it
// is still what marks the window fullscreen for this decision.
@Suppress("DEPRECATION")
if (transparent) {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
} else {
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
}
}
@@ -38,12 +38,6 @@ class JellyTauPlayer(private val appContext: Context) {
/** AudioEffect priority. Positive = higher priority than the default. */
private const val EFFECT_PRIORITY = 1000
/**
* How long to wait for a fresh frame after a resize before revealing the
* view anyway. Playback may be paused, in which case no frame is coming.
*/
private const val FRESH_FRAME_TIMEOUT_MS = 400L
/**
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
@@ -236,12 +230,6 @@ class JellyTauPlayer(private val appContext: Context) {
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
private var videoSurface: android.view.Surface? = null
/**
* True while the view is hidden waiting for a new frame after a resize.
* See fitSurfaceToScreen (DR-194).
*/
@Volatile
private var awaitingFreshFrame = false
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -1141,12 +1129,15 @@ class JellyTauPlayer(private val appContext: Context) {
isOpaque = true
// Own the listener rather than calling `setVideoTextureView`,
// which installs ExoPlayer's own and leaves us blind to frame
// arrival. `onSurfaceTextureUpdated` is the only honest signal
// that a NEW frame has landed in the texture, and that is
// precisely what the letterbox artefact waits on — see
// fitSurfaceToScreen. Handing ExoPlayer the Surface directly is
// the same wiring `setVideoTextureView` does internally.
// which installs ExoPlayer's own. Handing ExoPlayer the Surface
// directly is the same wiring `setVideoTextureView` does
// internally, and owning the listener keeps surface creation and
// teardown symmetrical with `videoSurface` below.
//
// (This was originally introduced to observe frame arrival for
// the letterbox artefact. That turned out to be the wrong lead —
// see fitSurfaceToScreen — but the explicit wiring is worth
// keeping on its own terms.)
//
// TRACES: UR-003, UR-004 | DR-194
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
@@ -1180,12 +1171,6 @@ class JellyTauPlayer(private val appContext: Context) {
override fun onSurfaceTextureUpdated(
texture: android.graphics.SurfaceTexture
) {
// A genuinely new frame is now in the texture, so
// whatever was retained from before the resize is gone.
if (awaitingFreshFrame) {
awaitingFreshFrame = false
videoView?.alpha = 1f
}
}
}
}
@@ -1258,31 +1243,6 @@ class JellyTauPlayer(private val appContext: Context) {
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
* video to the surface bounds, which crops the bottom on rotation.
*/
/**
* Hide the video view now, and keep it hidden until a genuinely new frame
* arrives (or the timeout fires).
*
* Called from `MainActivity.onConfigurationChanged`, i.e. at the *start* of a
* rotation. [fitSurfaceToScreen] is too late for this: it runs from the
* content view's layout listener, after the rotation has already happened,
* so the stale frame has been on screen for the whole transition by then.
*
* TRACES: UR-003, UR-066 | DR-194
*/
fun hideUntilFreshFrame() {
mainHandler.post {
val view = videoView ?: return@post
awaitingFreshFrame = true
view.alpha = 0f
mainHandler.postDelayed({
if (awaitingFreshFrame) {
awaitingFreshFrame = false
videoView?.alpha = 1f
}
}, FRESH_FRAME_TIMEOUT_MS)
}
}
fun fitSurfaceToScreen() {
mainHandler.post {
val view = videoView ?: return@post
@@ -1318,43 +1278,19 @@ class JellyTauPlayer(private val appContext: Context) {
lp.gravity = android.view.Gravity.CENTER
}
// Hide the view across a resize, and reveal it when a genuinely NEW
// video frame lands in the texture.
// Deliberately no alpha-hiding across the resize.
//
// A TextureView retains its last frame. Between a rotation and this
// re-fit landing, that retained frame is stretched across the OLD
// rect — larger than the new one along at least one axis — so the
// previous frame flashes in what should be the letterbox bars.
//
// Waiting a fixed number of animation frames does NOT fix it, which
// the first attempt at this proved on device: an animation frame is
// not a video frame, and at 24fps the next decoded frame can be
// several vsyncs away. The tell was that pausing and playing cleared
// the artefact by hand — that forces a fresh frame, which is the
// real precondition. So the reveal is driven by
// `onSurfaceTextureUpdated` instead.
//
// The timeout is not belt-and-braces, it is required: if playback is
// paused when the resize happens, no new frame is coming and the
// video would stay invisible forever. Revealing a stale frame after
// a beat is strictly better than a permanently black player.
//
// Scoped to an actual size change so steady-state playback never
// touches alpha.
// Two earlier attempts hid the view here (and from
// onConfigurationChanged) until a fresh frame landed, on the reading
// that the letterbox flash was a retained TextureView frame drawn at
// the old size. It was not: the bars were showing stale *framebuffer*
// content because nothing painted them — see the window-background
// note in MainActivity.setTransparent. Hiding the video view made
// that strictly worse, since the TextureView is the one view in the
// hierarchy that reliably paints its own rect; dropping its alpha to
// 0 simply widened the un-painted area.
//
// TRACES: UR-003, UR-066 | DR-194
val sizeChanged = lp.width != targetW || lp.height != targetH
if (sizeChanged) {
awaitingFreshFrame = true
view.alpha = 0f
mainHandler.postDelayed({
if (awaitingFreshFrame) {
awaitingFreshFrame = false
videoView?.alpha = 1f
}
}, FRESH_FRAME_TIMEOUT_MS)
}
lp.width = targetW
lp.height = targetH
view.layoutParams = lp
+70 -9
View File
@@ -863,6 +863,33 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
)
}
/// Build the Jellyfin endpoint for a Next Up listing.
///
/// `EnableResumable=false` is the point of this query: the server default is
/// `true`, which makes a partially-watched episode its own series' "next up" —
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
/// end up showing the same cards. Next Up should only ever offer episodes the
/// viewer has not started. Servers predating the parameter ignore it, which is
/// why the frontend also drops in-progress entries (DR-197).
///
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
/// HTTP server, matching `build_favorites_endpoint`.
///
/// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
let mut endpoint = format!(
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
user_id,
limit.unwrap_or(16)
);
if let Some(sid) = series_id {
endpoint.push_str(&format!("&SeriesId={}", sid));
}
endpoint
}
/// Build the Jellyfin endpoint for a favourites listing.
///
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
@@ -1153,15 +1180,7 @@ impl MediaRepository for OnlineRepository {
series_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!(
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_str
);
if let Some(sid) = series_id {
endpoint.push_str(&format!("&SeriesId={}", sid));
}
let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
@@ -3471,6 +3490,48 @@ mod tests {
assert!(endpoint.contains("Limit=16"));
}
/// UT-190 — Next Up asks the server to leave resumable episodes out.
///
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
/// the *in-progress* episode as a series' next up — exactly the episode
/// `/Items/Resume` already returns, so Continue Watching and Next Up render
/// the same cards.
///
/// TRACES: UR-059 | DR-197, JA-036 | UT-190
#[test]
fn test_build_next_up_endpoint_excludes_resumable() {
let endpoint = build_next_up_endpoint("u1", None, Some(12));
assert!(
endpoint.contains("EnableResumable=false"),
"next up must exclude in-progress episodes, got: {}",
endpoint
);
assert!(endpoint.contains("UserId=u1"));
assert!(endpoint.contains("Limit=12"));
assert!(
!endpoint.contains("SeriesId"),
"no series filter when none was requested, got: {}",
endpoint
);
}
/// UT-191 — a per-series Next Up query keeps the series filter.
///
/// TRACES: UR-059 | DR-197 | UT-191
#[test]
fn test_build_next_up_endpoint_scopes_to_series() {
let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
assert!(endpoint.contains("SeriesId=series-a"));
assert!(endpoint.contains("EnableResumable=false"));
assert!(
endpoint.contains("Limit=16"),
"default limit, got: {}",
endpoint
);
}
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
///
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.6.0",
"version": "0.7.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+61 -1
View File
@@ -72,6 +72,7 @@
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
shouldResumeOnForeground,
planHandoffReturn,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
@@ -1736,7 +1737,17 @@
hasPerformedInitialSeek = true;
lastAppliedInitialPosition = initialPosition;
pendingForegroundPlay = wasPlaying;
// How to come back depends on which renderer is actually on screen. See
// planHandoffReturn: the webview element resumes off its stream URL, the
// native backend only ever resumes off an explicit load.
const plan = planHandoffReturn({
useHtml5Element,
position: pos,
wasPlaying,
nativeStateKind: get(playerState).kind,
});
pendingForegroundPlay = plan.shouldPlay;
// Determine the target URL + how the element/offset should be positioned.
let targetUrl: string;
@@ -1761,6 +1772,55 @@
pendingForegroundSeek = pos;
}
if (plan.target === "native-backend" && media) {
// ExoPlayer has no element and nothing watches the stream URL for it, so
// the URL dance below would restart precisely nothing — which is exactly
// what shipped: the backend came back from the handoff holding no item,
// leaving a black screen with a play overlay stuck at 0:00 and a play
// button that did nothing (there was nothing loaded to play).
//
// Re-issue the same pair the initial load does, in the same order:
// player_play_item hands ExoPlayer the item and its sideloaded subtitle
// configurations (which cannot be added after prepare()), then the
// adapter load carries the resume position. `sentSubtitleTracks` was
// resolved during onMount for this same item, so it is reused rather
// than re-fetched.
//
// TRACES: UR-040, UR-003 | DR-196
currentStreamUrl = targetUrl;
await commands.playerPlayItem({
streamUrl: targetUrl,
title: media.name,
id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding,
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
});
didStartNativePlayback = true;
await playerAdapter?.load(targetUrl, {
mediaId: media.id,
mediaSourceId: mediaSourceId ?? null,
needsTranscoding,
initialPosition: plan.position,
isLive,
audioTrackIndex: selectedAudioTrackIndex ?? null,
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
subtitleTracks: sentSubtitleTracks.map((t) => ({
index: t.streamIndex,
url: t.url,
language: t.srclang,
label: t.label,
mimeType: "text/vtt",
})),
});
currentTime = plan.position;
// The load starts playing; honour a pause taken on the lockscreen.
if (!plan.shouldPlay) {
await playerController.pause();
}
return;
}
// Force the HLS-init $effect to re-run even if the URL string is unchanged:
// blank it first, then set it on the next microtask so Svelte sees a real
// transition. Without this, assigning the same value is a no-op and the
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
computeHandoffPosition,
planHandoffReturn,
initialHandoffState,
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
@@ -82,4 +83,57 @@ describe("backgroundAudioHandoff", () => {
expect(shouldResumeOnForeground(true, undefined)).toBe(true);
});
});
// Returning from background audio has to restart whatever is actually
// rendering. The webview <video> reloads off its stream URL, but the native
// (ExoPlayer) path owns no element and no URL-driven effect — its playback is
// only ever started by an explicit backend load. The component used to just
// reassign the stream URL and call it done, which on the native path restarted
// nothing: the player sat IDLE on a black screen with a play overlay, and the
// play button did nothing because the backend held no item.
//
// TRACES: UR-040, UR-003 | DR-196
describe("planHandoffReturn", () => {
it("restarts the native backend when the native path is rendering", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 4214,
wasPlaying: true,
nativeStateKind: "playing",
});
expect(plan.target).toBe("native-backend");
expect(plan.position).toBe(4214);
expect(plan.shouldPlay).toBe(true);
});
it("reloads the webview element when HTML5 is rendering", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 120,
wasPlaying: true,
nativeStateKind: "playing",
});
expect(plan.target).toBe("html5-element");
});
it("honours a lockscreen pause over the handoff snapshot", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 300,
wasPlaying: true,
nativeStateKind: "paused",
});
expect(plan.shouldPlay).toBe(false);
});
it("never returns a negative resume position", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: -3,
wasPlaying: false,
nativeStateKind: undefined,
});
expect(plan.position).toBe(0);
});
});
});
@@ -74,3 +74,50 @@ export function shouldResumeOnForeground(
): boolean {
return wasPlaying && nativeStateKind !== "paused";
}
/** What has to be restarted to put picture back on screen, and how. */
export interface HandoffReturn {
/** Which renderer must be brought back. */
target: "html5-element" | "native-backend";
/** Absolute position the background audio reached. */
position: number;
/** Whether playback should be running once it is back. */
shouldPlay: boolean;
}
/**
* How to come back when the app returns to the foreground.
*
* The two render paths resume by completely different means, and conflating
* them is what broke the native one:
*
* - **html5-element** assigning the stream URL is enough. An `$effect` in the
* component watches it, (re)initialises HLS or sets `videoElement.src`, and
* `canplay` then drives the seek and play.
* - **native-backend** ExoPlayer owns no element, and nothing reacts to the
* stream URL on its behalf. Native playback is only ever started by an
* explicit backend load, which the component issues once, from `onMount`. So
* the return has to re-issue it; reassigning the URL restarts nothing.
*
* The component previously did only the URL assignment, for both paths. On the
* native path that left the backend holding no item at all: a black screen with
* a play overlay, a play button that did nothing, and the position pinned at
* 0:00 the handoff's own audio player having been stopped on the way out.
*
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
* the handoff still wins over the snapshot taken on the way out.
*
* TRACES: UR-040, UR-003 | DR-196 | UT-060
*/
export function planHandoffReturn(opts: {
useHtml5Element: boolean;
position: number;
wasPlaying: boolean;
nativeStateKind: string | undefined;
}): HandoffReturn {
return {
target: opts.useHtml5Element ? "html5-element" : "native-backend",
position: opts.position > 0 ? opts.position : 0,
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
};
}
+52 -2
View File
@@ -6,12 +6,18 @@
* episode. Otherwise skipping an episode leaves it lingering as a resume
* suggestion behind the episode the user is actually on.
*
* TRACES: UR-059 | DR-089
* The mirror case: an episode still under way must not appear in Next Up, which
* is what made the two rows render the same cards.
*
* TRACES: UR-059 | DR-089, DR-197 | UT-192
*/
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
import {
filterSupersededResumeItems,
filterInProgressNextUpItems,
} from "./continueWatchingFilter";
function episode(
id: string,
@@ -122,3 +128,47 @@ describe("filterSupersededResumeItems", () => {
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
});
describe("filterInProgressNextUpItems", () => {
it("drops the episode the viewer is mid-way through", () => {
// The same episode in both lists is the duplicate-row bug: an in-progress
// episode belongs to Continue Watching, never to Next Up.
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e4", "series-a", 1, 4)];
expect(filterInProgressNextUpItems(nextUp, resume)).toEqual([]);
});
it("keeps the genuinely unstarted next episode", () => {
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterInProgressNextUpItems(nextUp, resume).map(i => i.id)).toEqual(["s1e5"]);
});
it("only suppresses the started episode, not the rest of the row", () => {
const resume = [episode("a-s1e4", "series-a", 1, 4)];
const nextUp = [
episode("a-s1e4", "series-a", 1, 4),
episode("b-s1e1", "series-b", 1, 1),
episode("c-s2e3", "series-c", 2, 3),
];
const result = filterInProgressNextUpItems(nextUp, resume);
expect(result.map(i => i.id)).toEqual(["b-s1e1", "c-s2e3"]);
});
it("is a no-op when nothing is in progress", () => {
const nextUp = [episode("s1e1", "series-a", 1, 1)];
expect(filterInProgressNextUpItems(nextUp, [])).toHaveLength(1);
});
it("ignores resume entries for other media", () => {
const resume = [movie("movie-1")];
const nextUp = [episode("s1e1", "series-a", 1, 1)];
expect(filterInProgressNextUpItems(nextUp, resume)).toHaveLength(1);
});
});
+27 -1
View File
@@ -9,7 +9,10 @@
// This is presentation-layer de-duplication over two lists the frontend already
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
//
// TRACES: UR-059 | DR-089
// The mirror image lives here too: an episode that is *in progress* belongs to
// Continue Watching and must not also headline Next Up.
//
// TRACES: UR-059 | DR-089, DR-197
import type { MediaItem } from "$lib/api/types";
/**
@@ -73,3 +76,26 @@ export function filterSupersededResumeItems(
return !isAheadOf(ahead, item);
});
}
/**
* Drop Next Up entries the viewer has already started.
*
* Jellyfin's `/Shows/NextUp` treats a partially-watched episode as its series'
* next up, so the same episode arrives in both lists and the two rows render
* identical cards. The backend asks the server to exclude those
* (`EnableResumable=false`), but servers predating that parameter ignore it
* so an episode present in the resume list is removed here as well. The split
* is then clean: Continue Watching offers unfinished episodes, Next Up offers
* unstarted ones.
*
* TRACES: UR-059 | DR-197
*/
export function filterInProgressNextUpItems(
nextUpItems: MediaItem[],
resumeItems: MediaItem[]
): MediaItem[] {
if (resumeItems.length === 0) return nextUpItems;
const inProgress = new Set(resumeItems.map(item => item.id));
return nextUpItems.filter(item => !inProgress.has(item.id));
}
+12 -5
View File
@@ -1,9 +1,12 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034, UR-059, UR-067 | DR-026, DR-027, DR-038, DR-039, DR-089, DR-118
// TRACES: UR-023, UR-024, UR-034, UR-059, UR-067 | DR-026, DR-027, DR-038, DR-039, DR-089, DR-118, DR-197
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
import {
filterSupersededResumeItems,
filterInProgressNextUpItems,
} from "./continueWatchingFilter";
interface HomeState {
heroItems: MediaItem[];
@@ -64,11 +67,15 @@ function createHomeStore() {
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
const rawNextUp = valueOr(1, [] as typeof initialState.nextUpItems);
// Drop episodes the user has already moved past (their series' Next Up
// points further ahead) so Continue Watching isn't cluttered with stale
// partial positions left behind by skipping.
const resume = filterSupersededResumeItems(rawResume, nextUp);
// partial positions left behind by skipping. The frontier is read from the
// unfiltered Next Up list, before in-progress entries are removed from it.
const resume = filterSupersededResumeItems(rawResume, rawNextUp);
// ...and the other way round: an episode already under way is Continue
// Watching's, so Next Episode only offers unstarted ones.
const nextUp = filterInProgressNextUpItems(rawNextUp, rawResume);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
import { get } from "svelte/store";
/**
* The stored value of the native-video preference, and what it means.
*
* The default has moved four times (see the history on `load()` in
* nativeVideo.ts), so the risk here is not "which way is it pointing" it is
* that a flip silently overrides people who chose. The old reader was
* `getItem(KEY) === "true"`, which conflates "never chose" with "chose off";
* flipping the default under that reader re-enables the native path for
* everyone who deliberately turned it off. So the three cases are pinned
* separately rather than through the default alone.
*
* TRACES: UR-003, UR-004 | DR-188
*/
const STORAGE_KEY = "jellytau-experimental-native-video";
// jsdom here doesn't expose localStorage; stand in a minimal implementation,
// matching the viewMode/searchGroupOrder store tests.
const backing = new Map<string, string>();
const localStorageShim = {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => void backing.set(key, value),
removeItem: (key: string) => void backing.delete(key),
clear: () => backing.clear(),
};
beforeAll(() => {
vi.stubGlobal("localStorage", localStorageShim);
});
afterAll(() => {
vi.unstubAllGlobals();
});
async function freshStore() {
// The default is read at module init, so each case needs a fresh module.
vi.resetModules();
return await import("./nativeVideo");
}
describe("experimentalNativeVideo default", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to ON when the user has never chosen", async () => {
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("stays OFF for someone who deliberately turned it off", async () => {
// The regression the null check exists for: an explicit opt-out must
// survive the default flip, not be re-enabled by it.
localStorage.setItem(STORAGE_KEY, "false");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(false);
});
it("stays ON for someone who deliberately turned it on", async () => {
localStorage.setItem(STORAGE_KEY, "true");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("persists an explicit choice in both directions", async () => {
const { experimentalNativeVideo } = await freshStore();
experimentalNativeVideo.set(false);
expect(localStorage.getItem(STORAGE_KEY)).toBe("false");
experimentalNativeVideo.set(true);
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
});
});
+27 -18
View File
@@ -50,30 +50,37 @@ const NATIVE_VIDEO_ATTR = "data-native-video";
*
* The picture is genuinely fixed and device-verified `WebView transparent =
* true` and `Marking media ready` now appear in logcat with video on screen,
* the pair DR-172 went looking for and could not find. **The default is still
* off**, because turning it on surfaced a different gap: the background-audio
* handoff (UR-040) can only *return* through the HTML5 element.
* `applyPendingForegroundSeek` bails on `!videoElement`, the HLS re-init effect
* bails on `!useHtml5Element`, and `handleCanPlay` the event that owns the
* post-handoff position and play state is an element event that never fires
* natively. So coming back from background audio leaves playback dead.
* the pair DR-172 went looking for and could not find. The default nonetheless
* stayed **off** for a further release, because turning it on surfaced a
* different gap: the background-audio handoff (UR-040) could only *return*
* through the HTML5 element, so coming back from background audio left playback
* dead. That was the same shape of mistake as DR-161 a verified sub-path
* shipped as a default over an unverified one so the flip waited (DR-190).
*
* That is the same shape of mistake as DR-161: a verified sub-path shipped as a
* default over an unverified one. The evidence standard this branch set for the
* picture applies to the handoff too, so the flip waits for it (DR-190).
* - **on** now. The two defects that were holding it back are fixed and
* verified on a device: the handoff return restarts the renderer that is
* actually on screen rather than only ever reloading the `<video>` element
* (DR-196), and the letterbox bars are painted instead of retaining whatever
* was last in the framebuffer (DR-194). The evidence standard this default
* has been held to since DR-161 is met for both: audio handoff at 69:54
* returning to video playing at 70:18, and clean bars across playback, the
* control bar and a rotation round-trip.
*
* An explicit stored choice still wins in both directions, so anyone who turned
* it on keeps it on.
* it off keeps it off hence the `null` check rather than a bare `=== "true"`,
* which would silently re-enable it for people who opted out.
*
* TRACES: UR-003, UR-004 | DR-188
*/
function load(): boolean {
if (typeof localStorage === "undefined") return false;
if (typeof localStorage === "undefined") return true;
try {
return localStorage.getItem(STORAGE_KEY) === "true";
const stored = localStorage.getItem(STORAGE_KEY);
// Never chosen → on. Chosen → honour it, in both directions.
return stored === null ? true : stored === "true";
} catch {
// Private-mode / disabled storage — default to the path whose handoff works.
return false;
// Private-mode / disabled storage — same default as a fresh install.
return true;
}
}
@@ -101,9 +108,11 @@ function createExperimentalNativeVideoStore() {
}
/**
* User opt-in for the native Android video path. **Defaults to off** see
* `load()`. The name says "experimental" because the flag remains a suppressor
* of Rust's backend choice, not a promoter of it.
* User preference for the native Android video path. **Defaults to on** see
* `load()`. The name still says "experimental" because the flag remains a
* suppressor of Rust's backend choice, not a promoter of it: turning it off
* forces the webview element, turning it on never produces a native backend
* where Rust says HTML5.
*/
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
+10 -4
View File
@@ -1,11 +1,14 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089, DR-197
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
import {
filterSupersededResumeItems,
filterInProgressNextUpItems,
} from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -63,7 +66,7 @@ function createTvStore() {
try {
const repo = auth.getRepository();
const [resume, nextUp, latest, surprise] = await Promise.all([
const [resume, rawNextUp, latest, surprise] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
@@ -86,8 +89,11 @@ function createTvStore() {
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
rawNextUp
);
// And drop from Next Up the episodes that are already under way — those
// are Continue Watching's, or the two rows show the same cards.
const nextUp = filterInProgressNextUpItems(rawNextUp, resume);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
+2 -3
View File
@@ -746,9 +746,8 @@
Decode video with the device's hardware decoder instead of the
built-in web player, for better performance and battery life,
and so picture-in-picture shows the video rather than the app.
The picture works, but background audio does not come back from
the lockscreen on this path yet — leave it off unless you are
helping test it.
On by default. Turn it off to fall back to the built-in web
player if a video misbehaves.
</p>
</div>
<button