Compare commits

...
14 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
dtourolle 8e98e1c37a test(player): answer the commands the tap-surface tests actually render
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 22m57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m50s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 7m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 20m32s
Build & Release / Build Windows (push) Successful in 14m29s
Build & Release / Build Android (push) Successful in 31m5s
Build & Release / Create Release (push) Successful in 12s
VideoPlayer.tapSurface.test.ts deliberately does not mock $lib/api/bindings — it
renders the real component against the real bindings, which bottom out in the
globally mocked `invoke`. That mock resolves `undefined` for every command, so
any command whose result is *rendered* blows up: the quality picker assigns the
result straight to state and the template then reads `streamingQualities.length`,
which throws on undefined.

It threw asynchronously, outside any test, so the suite reported 4 unhandled
errors while every test still passed — the state vitest warns "might cause false
positive tests". Answering the two rendered commands removes them.

Authored in the main checkout; brought in here and verified: 83 files, 1009
tests, and the unhandled-error count drops from 4 to 0.
2026-08-16 21:20:55 +02:00
dtourolle 440d7a01a9 chore(release): 0.6.0
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m2s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 32s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 6m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Android native video renders a picture, and its transport works.

The path shipped once as audio with no picture and was reverted with the
compositing named as the suspect. It was not the compositing: five independent
defects sat between ExoPlayer and the screen, each able to produce that symptom
on its own — the app shell painting over the surface through a CSS rule aimed at
an attribute nothing set, a poster card with no way to lift on a path that
renders no <video>, JS bridges racing the page load and losing permanently, a
SurfaceView that was never detached, and a frontend that told Rust a webview
element was playing when none existed, so every play/pause intent was aimed at
something that was not there.

Native video stays opt-in. Turning it on surfaced a further unverified path —
the background-audio return is written only for the webview element — and
rotation still needs device confirmation.

Minor rather than patch: the player's touch behaviour changes for everyone (the
control bar now auto-hides on touchscreens, and the system bars go away with the
player), not only for those who opt into native video.
2026-08-16 21:14:08 +02:00
dtourolle dccb5f53dd fix(android): stop the rotation cross-fade replaying the old video frame
Rotating with native video on shows the previous frame flashing in what become
the letterbox bars. It reads as a TextureView artefact — the view retains its
last frame, so between the rotation and fitSurfaceToScreen() landing that frame
sits at the old size — and two fixes were built on that reading:

  1. reveal after two postOnAnimation hops. An animation frame is not a video
     frame; at 24fps the next decoded frame can be several vsyncs away.
  2. reveal on onSurfaceTextureUpdated, i.e. when a real frame lands. This meant
     owning the SurfaceTextureListener and handing ExoPlayer the Surface directly
     instead of via setVideoTextureView, which installs its own and leaves us
     blind to frame arrival.

Neither stopped the flash. The mechanism is the WINDOW's rotation animation:
Android cross-fades a screenshot of the old orientation, that screenshot holds
the old video frame at the old size, and nothing at the TextureView level can
reach it. The app cannot pre-empt the screenshot either — onConfigurationChanged
fires after it is taken.

So the animation itself has to go: ROTATION_ANIMATION_JUMPCUT. That was accepted
and silently ignored, and the platform said why out loud —
"VRI[MainActivity]: setLayoutParams: not fullscreen" — because the attribute is
honoured only for a fullscreen window. FLAG_FULLSCREEN is therefore set with it,
scoped to while native compositing is active so the rest of the app keeps its
normal animation. After the change that complaint is gone from logcat.

The frame-arrival reveal is kept: it replaces a fixed-timeout guess with a real
signal, and its timeout is required rather than defensive — a resize while paused
means no new frame is ever coming, and revealing a stale frame beats a
permanently black player.

NOT CONFIRMED FIXED on device. The forced-rotation harness
(settings put system user_rotation) proved unreliable here, and screenrecord
fixes its canvas at start, so a rotation inside a recording never changes frame
dimensions — which defeated two separate attempts to measure this. DR-194 is
recorded as "Needs device verification" rather than Done.
2026-08-16 18:46:15 +02:00
dtourolle c142568230 fix(player): make transport reach the player that is actually rendering
Play/pause did nothing on the Android native video path — from the on-screen
tap, from the control bar, and from a direct player_toggle invocation — while
seek and skip kept working. That asymmetry was the whole clue: seek decides in
player_seek_video, transport decides in toggle_playback.

DR-195 is the cause. `html5_playing` is Rust's record of "a webview <video> is
active and in this state", and toggle_playback/play/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. It also explains
the flashing: the control bar and the JRay overlay both key off isPlaying, which
was being contradicted on every tick. The mirror now lives in
mirrorElementStateToRust() in VideoPlayer, gated on useHtml5Element — the only
place that knows whether an element renders at all. The route cannot tell the
paths apart, which is exactly how it came to lie.

DR-193 hands transport authority back to the native backend when an item loads
into it. Necessary but insufficient alone: the progress interval put the flag
straight back, which is why the first device test after it still failed.

DR-192 presents native video through a TextureView instead of a SurfaceView. A
SurfaceView renders on its own layer outside the app window and punches a
transparent region through it, and everything drawn above that hole — here, the
entire Svelte UI — depends on that composition path. The overlay dropped its
incremental damage: the DOM advanced (slider 476 -> 479 across three seconds)
behind a screen showing neither, so the progress bar froze, controls would not
fade and rotation lost the transport UI, while structural DOM changes got
through, which is why the play overlay always appeared to work. It supersedes
DR-191, which forced redraws in a loop and treated the symptom.

DR-194 hides the video view across a resize and reveals it two frames later. A
TextureView retains its last frame, so between a rotation and the re-fit landing
that frame is stretched across the old rect and the previous frame flashes in
what should be the letterbox bars.

Verified on device (Honor ROD2-W09, Android 16) by driving ADB and reading the
live DOM over the devtools socket: surface tap pauses (position frozen across 12
seconds, overlay raised, transport flipped) and resumes; the control bar does
both. UT-189 drives the real 10-second interval under fake timers — an earlier
version asserted on a freshly mounted player, passed with the guard deleted, and
guarded nothing.

Still open, and deliberately not claimed: DR-192's effect on the overlay repaint
is unverified on device, DR-194's letterbox reset is untested, and the native
default (DR-188) stays off pending DR-190, the background-audio return.
2026-08-16 18:03:22 +02:00
dtourolle 95129d04a3 fix(player): make Android native video actually visible, and usable
DR-172 reverted native video to opt-in after it shipped as audio with no
picture, naming the compositing as the suspect. The compositing was fine. Five
separate defects sat between ExoPlayer and the screen, each able to produce that
exact symptom on its own, and each invisible to the others.

DR-185 — the app shell painted over the surface. app.css clears the page's
opaque layers through three selectors, one of which targets `[data-app-shell]`,
an attribute NO component has ever set, in any commit. The shell paints
--color-background across the whole viewport and VideoPlayer stacks above it, so
the WebView composited opaque no matter what else was cleared. 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.

DR-182 — nothing could lift the poster card. Every markMediaReady() call site is
an HTML5 <video> event, and the native branch renders no element, so the black
title card covered the surface for the entire session. The first fix hooked
`player://position-update` / `player://state-changed`; those channels are never
emitted by the backend, so it passed a test that fired them by hand and did
nothing on a device. Driven from the player store now, as the seek bar already
was.

DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by
walking the view tree, while WebView binds injected objects at page-load time,
and the identity guard then declined to re-inject forever. setTransparent(true)
could never arrive. Installed from WryActivity.onWebViewCreate instead, which
wry calls immediately before the first loadUrl.

DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers
anywhere, mirroring the DR-151 defect: every native video left its surface
parented to the content view and the next one stacked another beneath it.

DR-191 — the overlay stopped repainting. Incremental damage (the clock's text,
the control bar's opacity) never reached the screen while structural changes did,
so the progress bar froze, the controls would not fade, and the play overlay
appeared to work because it is added and removed from the DOM. Driven from the
Activity via postInvalidateOnAnimation while compositing is on.

Two UI defects only this path could reveal came with them: isPlaying froze at
its initial value, leaving the play overlay dimming and covering the video
(DR-186), and the control bar's auto-hide was armed solely by mousemove, which a
touchscreen never fires (DR-189). Immersive mode now applies on entering the
player rather than only via the fullscreen button (DR-187).

Verified on a device (Honor ROD2-W09, Android 16): logcat carries
`WebView transparent = true` and `Marking media ready` with video on screen —
the pair DR-172 went looking for and could not find — and skip, seek, rotation
and subtitle rendering were exercised by hand.

The default stays OFF (DR-188). Turning it on surfaced a further unverified
sub-path: returning from background audio is HTML5-only, so playback stays dead
(DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified
sub-path made default over an unverified one.
2026-08-16 15:28:10 +02:00
dtourolle f0f98feae8 fix(player): strip the burn-in the server puts back into its own transcode URL
The negotiation asks for no subtitle stream (DR-176), but when PlaybackInfo
answers with a TranscodingUrl we played that URL verbatim — and the server
built it from its own subtitle verdict. Jellyfin's StreamInfo.ToUrl appends
SubtitleStreamIndex and SubtitleMethod whenever it picked a track, so the
burn-in we had just declined came straight back through the URL, turning a
remux into a full frame-by-frame re-encode.

Live TV never declined it at all: open_live_stream sent no index, so the
server applied the channel's default track, and broadcast subtitles are DVB
bitmaps that NormalizeSubtitleEmbed converts to burn-in on sight.

without_server_chosen_subtitle() drops SubtitleStreamIndex, SubtitleMethod,
SubtitleCodec and alwaysBurnInSubtitleWhenTranscoding from any URL the server
built — matched case-insensitively, as Jellyfin binds query keys — and
re-appends the -1 sentinel, because an absent index is not "none", it is
"you choose". Applied at both adoption points, plus the sentinel in the
live-stream negotiation body and its fallback URL.
2026-08-16 14:56:40 +02:00
dtourolle d9e1e256e9 fix(auth): trim the username before authenticating
The login form guarded on `username.trim()` but sent the raw value, so a
trailing space from a soft keyboard reached the server verbatim. Jellyfin
reports that as an unknown user, which surfaces as a 401 indistinguishable
from a wrong password — the user is certain of their credentials and the app
insists otherwise.

Normalising in AuthManager rather than the form keeps it on the path every
caller uses, alongside normalize_url. Only surrounding whitespace is
stripped; interior spaces are legal in Jellyfin usernames.
2026-08-16 11:31:34 +02:00
dtourolle 42868fc2e6 feat(login): reveal-password toggle, and stop the keyboard editing credentials
Add an eye/eye-off button inside the password field so a typed password can
be checked against what was intended — the difference between "wrong
password" and "wrong keyboard" was previously invisible.

`bind:value` is not allowed alongside a dynamic `type`, so the field is wired
manually via value/oninput; unlike branching on two separate inputs, this
keeps focus and caret position when the toggle is pressed.

Both fields also get autocapitalize/autocorrect/spellcheck off and proper
autocomplete hints. The Android soft keyboard was free to capitalise or
autocorrect the username, which silently changes a credential the user
believes they typed correctly.
2026-08-16 11:31:27 +02:00
37 changed files with 4739 additions and 2476 deletions
+103
View File
@@ -9,6 +9,109 @@ 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
- **Android native video actually shows a picture.** It shipped once as *audio
with no picture* and was reverted with the compositing named as the suspect
(DR-172). The compositing was not at fault; five independent defects sat
between ExoPlayer and the screen, each able to produce that symptom alone. The
app shell painted over the video surface through a CSS rule targeting
`[data-app-shell]`, an attribute no component had ever set in any commit
(DR-185). The poster/title card had no way to lift on a path that renders no
`<video>` element, so a black card covered the surface for the whole session
(DR-182). The JavaScript bridges were installed by a 500 ms tree walk that
raced the page load — and lost permanently when it lost, because the
re-injection guard then declined to retry — so `setTransparent(true)` could
never arrive (DR-183). The `SurfaceView` was never detached, leaking one per
video and leaving picture-in-picture's gate stuck open (DR-184). Verified on a
device: logcat now carries `WebView transparent = true` and
`Marking media ready` with video on screen, the pair the original
investigation went looking for and could not find.
(UR-003, UR-004, UR-041 → DR-182, DR-183, DR-184, DR-185)
- **Play and pause reach the player that is actually rendering.** Transport did
nothing on the native video path — from the on-screen tap, from the control
bar, and from a direct command invocation — while seek and skip kept working,
because those decide elsewhere. Rust routes play/pause to the webview `<video>`
whenever it believes one is active, and the player route mirrored element state
into that belief unconditionally, including from a ten-second progress
interval. So on the native path the frontend re-declared every ten seconds that
an element was playing when none existed, and every intent was emitted at
something that was not there. The mirror now lives where `useHtml5Element` is
known. This also explains the flashing transport controls, since they key off
the play state that was being contradicted on every tick.
(UR-005, UR-003 → DR-193, DR-195)
- **The player's controls hide themselves on a touchscreen.** The auto-hide timer
was armed only from `mousemove`, which a touch device never fires, so the
control bar stayed over the video for the whole film. It is now armed on entry
and on every touch, and pinned open while paused, seeking, or with a menu open.
(UR-003, UR-066 → DR-189)
- **The system bars go away with the player.** Immersive mode had exactly one
caller — the fullscreen button — so opening a video left the status and
navigation bars painted over it until the user pressed a control most never
press. (UR-066, UR-003 → DR-187)
### 🔬 Internal
- Native video presents through a `TextureView` rather than a `SurfaceView`. A
SurfaceView renders on its own layer outside the app window and punches a
transparent region through it, and Android's own graphics documentation warns
that overlays do not composite reliably above one. (UR-003, UR-004 → DR-192)
- Native Android video remains **opt-in**, and is not yet the default. Turning it
on surfaced a further unverified path: returning from background audio is
implemented only for the webview element, so playback stays dead on the native
path (DR-190, proposed). Rotation still needs device confirmation (DR-194).
(UR-003 → DR-188)
## v0.5.5
### ✨ Features
+30 -6
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
@@ -340,6 +341,21 @@ Internal architecture, components, and application logic.
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException``400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | Done |
| DR-182 | Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and **every** signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise | UI | UR-003, UR-004, UR-041 | Done |
| DR-183 | The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at **page-load time**: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is *permanent* when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently | Android | UR-003, UR-004, UR-040, UR-041 | Done |
| DR-184 | The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had **no callers anywhere in the tree** — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its *own* parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view | Android | UR-003, UR-041 | Done |
| 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: 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-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 |
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
@@ -361,9 +377,9 @@ 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 |
| 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 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179 |
| 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 | - |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
@@ -398,8 +414,8 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172 |
| 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 |
| UR-044 | - | DR-056 |
@@ -423,7 +439,7 @@ Internal architecture, components, and application logic.
| UR-063 | - | DR-105 |
| UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
| UR-066 | IR-031 | DR-112, DR-157 |
| UR-066 | IR-031 | DR-112, DR-157, DR-187, DR-194 |
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
| UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 |
@@ -619,6 +635,14 @@ Internal architecture, components, and application logic.
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
| 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
+28 -4
View File
@@ -1,8 +1,12 @@
# Spec: Android native video — transparent-webview spike
**Status:** Spike succeeded — native video confirmed working on a physical
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag,
default off. Branch `feat/android-native-video`.
**Status:** Spike succeeded (2026-08-11); shipped behind `experimentalNativeVideo`,
default off. Flipping that default shipped **audio with no picture** and was
reverted (DR-172). Three defects behind that have since been fixed — DR-182
(nothing on the native path could lift the poster overlay), DR-183 (the JS
bridges raced the page load), DR-184 (the SurfaceView was never detached).
Branch `fix/android-native-video-visible`. **The default stays off until the
device criteria below are green.**
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
composited behind a transparent Tauri WebView on Android. Nothing upstream
@@ -227,6 +231,9 @@ The spike is **complete** when one of these is true:
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities``usesWebviewAudio`).
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
- [x] The poster/title card comes down on the native path. It never could: every `markMediaReady()` call site is a `<video>` element event and the native branch renders no element, so an opaque `bg-black` overlay covered the ExoPlayer surface for the whole session. See DR-182; guarded by `mediaReady.test.ts` (UT-184) and `VideoPlayer.nativeReveal.test.ts` (UT-185), the latter written failing first.
- [x] The `AndroidVideoSurface` bridge is installed before the page that calls it loads, via `WryActivity.onWebViewCreate` instead of a 500 ms tree walk, and a missing bridge now logs an error instead of no-oping. See DR-183.
- [x] The SurfaceView is detached when video stops, instead of accumulating one leaked view per native video. See DR-184.
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
@@ -238,9 +245,26 @@ The spike is **complete** when one of these is true:
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
Either way:
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
- [x] `bun run check` (0 errors), `bun run test` (997 passed), `bun run check:boundary` pass.
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
### Why the 2026-08-11 verification and DR-172 do not contradict each other
The spike was reported working on device; the same path then shipped as audio
with no picture. Both are consistent with DR-182: the poster overlay is drawn
only while `isMediaReady` is false, and the native path has no way to set it, so
what the surface shows depends entirely on **whether that overlay is on screen**
— not on whether compositing works. Any run that reached the player through a
path leaving `isMediaReady` already true (a handoff return, a re-render, a
session that had previously played on the HTML5 path) shows video; a cold start
into the native path never does. That is also why DR-172 read the symptom as a
compositing failure: on screen the two are identical, and the one piece of
evidence separating them — `WebView transparent = true` never being logged —
points at DR-183 rather than at the compositing itself.
**This reasoning is not yet device-confirmed.** It explains the reports and is
backed by the code, but the criteria above are what settle it.
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
> no `bun`, so all of the above were run inside the CI builder image
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
+2733 -2299
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.5.5",
"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(172);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(314);
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.5.5"
version = "0.7.0"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.5.5"
version = "0.7.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -10,6 +10,7 @@ import android.webkit.WebView
import android.view.View
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0
@@ -52,6 +53,42 @@ class MainActivity : TauriActivity() {
*/
private var bridgesInstalledOn: WebView? = null
/**
* wry hands us the WebView here, and this is the only point at which the
* bridges can be installed *deterministically*.
*
* WebView binds an injected object into JS at **page-load time**: an
* addJavascriptInterface call that lands after the page has loaded does not
* appear to that page at all. The bridges used to be installed from
* [configureWebViewForMedia], which finds the WebView by walking the view
* tree 500 ms after onCreate a race against Tauri's own page load, and one
* that is *permanent* when lost, because the identity guard then declines to
* re-inject on the resume passes. The whole set (`AndroidVideoSurface`,
* `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`,
* `AndroidImmersive`, `AndroidInsets`) simply would not exist in `window`,
* silently: every one of them is called through an optional chain, so a
* missing bridge is a no-op rather than an error. That is a candidate
* explanation for DR-172's central piece of evidence native video shipped
* with `WebView transparent = false` logged and `= true` never appearing,
* i.e. the enable call never reaching Kotlin.
*
* `WryActivity.setWebView()` calls this immediately before wry issues the
* first `loadUrl`, so a bridge installed here is bound by the time any page
* runs. Note this can fire during `super.onCreate()`, i.e. *before* the rest
* of our own onCreate so only work that needs nothing but the WebView
* belongs here. Insets are deliberately left to
* [configureWebViewForMedia], which runs later and on every resume.
*
* TRACES: UR-003, UR-004 | DR-183
*/
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
installJavascriptBridges(webView)
configureWebViewSettings(webView)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -165,7 +202,9 @@ class MainActivity : TauriActivity() {
private fun configureWebViewForMedia() {
try {
val webView = findWebView(window.decorView)
// onWebViewCreate normally got here first; the tree walk is the fallback
// for a WebView we were never handed.
val webView = mediaWebView ?: findWebView(window.decorView)
if (webView == null) {
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
@@ -183,33 +222,47 @@ class MainActivity : TauriActivity() {
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
//
// configureWebViewForMedia() runs from onCreate's delayed post AND from
// every onResume (plus each WebView re-find), so this used to re-inject
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
// injected objects at page-load time; re-injecting over a live page
// leaves JS holding a stale proxy. The object stays truthy while its
// methods vanish, which surfaced as a flood of
// "WebView: Unknown object" chromium errors and, in JS,
// "TypeError: setEnabled is not a function".
//
// The visible bug: the background-audio toggle turned blue but never
// reached native, so backgroundAudioEnabled stayed false, onStop never
// dispatched 'jellytau-background', and a locked screen killed audio
// instantly (UR-040). Audio focus and PiP broke the same way.
//
// The settings/WebChromeClient work below is idempotent and must keep
// running on resume; only the bridge injection is one-shot.
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
// idempotent and MUST re-run: a page load discards the inline style the
// last push set, so the WebView would otherwise be left with no insets.
WindowInsetsBridge.attachWebView(webView)
// Normally already done by onWebViewCreate; this is the fallback path.
installJavascriptBridges(webView)
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
/**
* Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
*
* This runs from [onWebViewCreate] the only point early enough to be bound
* before the first page load and from [configureWebViewForMedia] as a
* fallback. The latter runs from onCreate's delayed post AND from every
* onResume (plus each WebView re-find), so without the identity guard this
* re-injected every bridge repeatedly 5 times in a 45s session. WebView
* binds injected objects at page-load time; re-injecting over a live page
* leaves JS holding a stale proxy. The object stays truthy while its methods
* vanish, which surfaced as a flood of "WebView: Unknown object" chromium
* errors and, in JS, "TypeError: setEnabled is not a function".
*
* The visible bug: the background-audio toggle turned blue but never reached
* native, so backgroundAudioEnabled stayed false, onStop never dispatched
* 'jellytau-background', and a locked screen killed audio instantly (UR-040).
* Audio focus and PiP broke the same way.
*
* Settings/WebChromeClient work is idempotent and must keep running on
* resume, so it lives in [configureWebViewSettings], not here.
*
* TRACES: UR-003, UR-004, UR-040, UR-041 | DR-183
*/
private fun installJavascriptBridges(webView: WebView) {
try {
if (webView === bridgesInstalledOn) {
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
configureWebViewSettings(webView)
return
}
bridgesInstalledOn = webView
@@ -317,16 +370,45 @@ 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.
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(color)
android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)
)
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
}
@@ -371,10 +453,8 @@ class MainActivity : TauriActivity() {
dispatchWebEvent("jellytau-network-changed")
}
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
android.util.Log.e("MainActivity", "Failed to install JavaScript bridges", e)
}
}
@@ -1,7 +1,7 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.view.SurfaceView
import android.view.TextureView
import android.view.ViewGroup
import android.widget.FrameLayout
import com.dtourolle.jellytau.player.JellyTauPlayer
@@ -14,15 +14,18 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
*/
object VideoOverlayManager {
private var attachedSurfaceView: SurfaceView? = null
private var attachedSurfaceView: TextureView? = null
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
private var listenerContentView: ViewGroup? = null
/**
* Attach the video SurfaceView to the Activity's content view.
* Attach the video view to the Activity's content view.
*
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
* Added at index 0 (bottom of the z-order) so it renders behind the Tauri
* WebView, allowing the Svelte controls to overlay on top. Since DR-192 this
* is a TextureView, so "behind" is ordinary view z-order within one window
* rather than a separate surface punched through it which is what makes
* the overlay above it repaint reliably.
*
* @param activity The Activity to attach the surface to
*/
@@ -77,16 +80,29 @@ object VideoOverlayManager {
}
/**
* Detach the video SurfaceView from the Activity's view hierarchy.
* Detach the video SurfaceView from the view hierarchy.
*
* @param activity The Activity to detach the surface from
* Must be called on the main thread.
*
* This had **no callers at all**, which made [attachVideoSurface] one-way:
* `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference
* without removing the view, so every native video left its SurfaceView
* parented to the content view for the life of the process and the next one
* added another beneath it. The stack was invisible while the WebView was
* opaque, and [isVideoSurfaceAttached] which gates
* `PictureInPictureManager.canEnterPip` stayed true forever afterwards.
*
* Removes from the view's *own* parent rather than looking the content view
* up from an Activity, so it cannot leave a view behind when the Activity
* has been recreated under it.
*
* TRACES: UR-003, UR-041 | DR-184
*/
fun detachVideoSurface(activity: Activity) {
fun detachVideoSurface() {
try {
removeLayoutListener()
attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
contentView.removeView(surfaceView)
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
attachedSurfaceView = null
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
}
@@ -8,8 +8,7 @@ import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.OptIn
@@ -225,9 +224,12 @@ class JellyTauPlayer(private val appContext: Context) {
/** Media type enum */
enum class MediaType { AUDIO, VIDEO }
/** SurfaceView for video playback */
private var surfaceView: SurfaceView? = null
private var surfaceHolder: SurfaceHolder? = null
/** TextureView for video playback — see getOrCreateSurfaceView() for why. */
private var videoView: TextureView? = null
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
private var videoSurface: android.view.Surface? = null
/** 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
@@ -1078,52 +1080,111 @@ class JellyTauPlayer(private val appContext: Context) {
}
/**
* Get or create the SurfaceView for video playback.
* Returns the view ID that can be attached to the view hierarchy.
* Get or create the video view, and hand it to ExoPlayer.
*
* Note: The surface is created but not automatically attached to the view hierarchy.
* Call attachSurfaceToActivity() or use VideoOverlayManager to attach it.
* This is a **TextureView**, not a SurfaceView, and that is the whole point.
*
* A SurfaceView renders on its own layer *outside* the app window and punches
* a transparent hole through the window to show it. Anything drawn above
* that hole for us, the entire Svelte UI in a transparent WebView is at
* the mercy of that composition path, and Android's own graphics
* documentation says plainly that "overlays do not currently work correctly
* with SurfaceView or TextureView". On device that showed up as the WebView
* overlay silently dropping its incremental damage: the clock text stopped
* advancing on screen while the DOM kept updating (slider 476 479 across
* three seconds behind a display showing neither), the control bar would not
* fade, and rotation lost the transport UI. Only *structural* DOM changes
* got through, which is why the play overlay an `{#if}` block that is added
* and removed always appeared to work while the progress bar never did.
*
* A TextureView is an ordinary view: its frames are drawn as a texture inside
* the window's normal rendering pass, so there is no second layer, no
* transparent region, and the WebView above composites like it would over any
* other view. This is the standard remedy for ExoPlayer overlay problems and
* is why media3 offers `surface_type="texture_view"` at all.
*
* The cost is real and accepted: TextureView uses more power and memory than
* SurfaceView and adds a frame of latency. Hardware decode through MediaCodec
* is unaffected only presentation changes so the reason native video
* exists survives the trade.
*
* `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so
* there is deliberately no listener of ours here; adding one would displace
* it and the video would never appear.
*
* Note: the view is created but not attached to the hierarchy. Call
* attachSurfaceToActivity() or use VideoOverlayManager to attach it.
*
* TRACES: UR-003, UR-004 | DR-192
*/
fun getOrCreateSurfaceView(): Int {
if (surfaceView == null) {
surfaceView = SurfaceView(appContext).apply {
if (videoView == null) {
videoView = TextureView(appContext).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Render BEHIND WebView - video shows through transparent areas
setZOrderMediaOverlay(false)
// The view is opaque where video is drawn; the WebView above it
// is what supplies transparency, exactly as before.
isOpaque = true
// Set up SurfaceHolder callbacks
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
android.util.Log.d("JellyTauPlayer", "Surface created")
surfaceHolder = holder
exoPlayer.setVideoSurfaceHolder(holder)
// Own the listener rather than calling `setVideoTextureView`,
// 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 {
override fun onSurfaceTextureAvailable(
texture: android.graphics.SurfaceTexture,
width: Int,
height: Int
) {
videoSurface?.release()
videoSurface = android.view.Surface(texture)
exoPlayer.setVideoSurface(videoSurface)
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
android.util.Log.d("JellyTauPlayer", "Surface changed: ${width}x${height}")
override fun onSurfaceTextureSizeChanged(
texture: android.graphics.SurfaceTexture,
width: Int,
height: Int
) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
android.util.Log.d("JellyTauPlayer", "Surface destroyed")
exoPlayer.clearVideoSurfaceHolder(holder)
surfaceHolder = null
override fun onSurfaceTextureDestroyed(
texture: android.graphics.SurfaceTexture
): Boolean {
exoPlayer.setVideoSurface(null)
videoSurface?.release()
videoSurface = null
return true
}
})
override fun onSurfaceTextureUpdated(
texture: android.graphics.SurfaceTexture
) {
}
}
}
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
}
return surfaceView!!.hashCode()
return videoView!!.hashCode()
}
/**
* Get the SurfaceView instance (for VideoOverlayManager).
* Returns null if no surface has been created yet.
* Get the video view instance (for VideoOverlayManager).
* Returns null if none has been created yet.
*/
fun getSurfaceView(): SurfaceView? {
return surfaceView
fun getSurfaceView(): TextureView? {
return videoView
}
/**
@@ -1138,7 +1199,7 @@ class JellyTauPlayer(private val appContext: Context) {
* This should be called from MainActivity when video playback is active.
*/
fun attachSurfaceToActivity(activity: android.app.Activity) {
if (surfaceView != null && currentMediaType == MediaType.VIDEO) {
if (videoView != null && currentMediaType == MediaType.VIDEO) {
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
}
@@ -1184,7 +1245,7 @@ class JellyTauPlayer(private val appContext: Context) {
*/
fun fitSurfaceToScreen() {
mainHandler.post {
val view = surfaceView ?: return@post
val view = videoView ?: return@post
val parent = view.parent as? ViewGroup
// Available area: prefer the parent's measured size, fall back to the screen.
val availW = parent?.width?.takeIf { it > 0 }
@@ -1216,6 +1277,20 @@ class JellyTauPlayer(private val appContext: Context) {
if (lp is FrameLayout.LayoutParams) {
lp.gravity = android.view.Gravity.CENTER
}
// Deliberately no alpha-hiding across the resize.
//
// 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
lp.width = targetW
lp.height = targetH
view.layoutParams = lp
@@ -1228,14 +1303,24 @@ class JellyTauPlayer(private val appContext: Context) {
}
/**
* Clear the video surface when switching to audio playback.
* Clear the video surface when switching to audio playback, or on stop.
*
* Detaching is not optional bookkeeping: dropping the reference without
* removing the view left the SurfaceView parented to the content view for
* the life of the process, and the next video stacked another one under it.
* See VideoOverlayManager.detachVideoSurface.
*
* Always called on the main thread (every caller runs inside a
* `mainHandler.post`), which is what touching the view hierarchy requires.
*
* TRACES: UR-003, UR-041 | DR-184
*/
private fun clearVideoSurface() {
surfaceView?.let {
videoView?.let {
exoPlayer.clearVideoSurface()
surfaceView = null
surfaceHolder = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared")
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
videoView = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
}
}
+33
View File
@@ -129,6 +129,18 @@ impl AuthManager {
Ok(normalized)
}
/// Normalize a username before it goes to the server.
///
/// Only surrounding whitespace is stripped — interior spaces are legal in
/// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
/// autocorrect makes the server report an unknown user, which surfaces as a
/// 401 that looks exactly like a wrong password.
///
/// TRACES: UR-042 | DR-054
pub fn normalize_username(username: &str) -> String {
username.trim().to_string()
}
/// Connect to server and get server info
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
let normalized_url = Self::normalize_url(server_url)?;
@@ -185,6 +197,7 @@ impl AuthManager {
) -> Result<AuthResult, String> {
let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Users/AuthenticateByName", url);
let username = Self::normalize_username(username);
log::info!("[AuthManager] Authenticating user: {}", username);
@@ -443,6 +456,26 @@ mod tests {
);
}
/// Usernames must be trimmed before they reach the server: the Android soft
/// keyboard appends a trailing space after autocorrect, and Jellyfin then
/// reports an unknown user — a 401 indistinguishable from a wrong password.
#[test]
fn test_normalize_username_trims_whitespace() {
assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
assert_eq!(AuthManager::normalize_username(" duncan "), "duncan");
assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
}
/// Interior spaces are legal in Jellyfin usernames and must survive.
#[test]
fn test_normalize_username_preserves_interior_spaces() {
assert_eq!(
AuthManager::normalize_username(" duncan tourolle "),
"duncan tourolle"
);
}
/// Test URL normalization - real world case
#[test]
fn test_normalize_url_real_world_case() {
+76
View File
@@ -506,6 +506,29 @@ impl PlayerController {
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
self.set_end_reason(EndReason::NewTrackLoaded);
// Loading into the native backend IS the statement that native renders
// this item, so transport authority returns to it.
//
// `html5_playing` is written only by the webview element's own reports
// and cleared only when it reports "stopped"/"idle". An element that
// went away without that final report — or webview-rendered music
// earlier in the same process — left `is_html5_active()` true, and then
// every play/pause intent was emitted as a ControlCommand at an element
// that no longer existed instead of reaching the backend. On Android's
// native video path that is a pause button that does nothing, from the
// surface tap and the control bar alike, while seek and skip keep
// working because they decide elsewhere. Whether it happened at all
// depended on what had played before, which is what made it look
// intermittent.
//
// The webview re-establishes its own authority the moment an element
// reports again, so nothing is lost on the HTML5 path: this is the same
// "element is gone" semantics as the "stopped"/"idle" report, applied at
// the point where we can know it directly.
//
// TRACES: UR-005, UR-003 | DR-193
*self.html5_playing.lock_safe() = None;
let mut backend = self.backend.lock_safe();
backend.load(item)?;
backend.play()?;
@@ -3200,6 +3223,59 @@ mod tests {
);
}
#[test]
fn test_native_load_returns_transport_authority_to_the_backend() {
// Play/pause did nothing on the Android native video path, from the
// on-screen tap AND from the control-bar button, while seek and skip
// worked — those take a different decision path.
//
// `html5_playing` is written only by the webview element's own reports
// and cleared only when it reports "stopped"/"idle" (or on a
// background-audio handoff). A previous element that went away without
// that final report — or webview-rendered music earlier in the same
// process — therefore left `is_html5_active()` true, and every transport
// intent was emitted as a ControlCommand at an element that no longer
// existed. Nothing reached ExoPlayer. It looked intermittent because it
// depends entirely on what played before.
//
// Loading into the native backend IS the statement that native renders
// this item, so it hands authority back — the same "element is gone"
// semantics the "stopped"/"idle" report already has.
//
// TRACES: UR-005, UR-003 | DR-193
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// A webview element reported itself playing and never said "stopped".
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
assert!(controller.is_html5_active());
// Now a native item loads — Android video through ExoPlayer.
let item = create_test_items(1).into_iter().next().unwrap();
controller.play_item(item).unwrap();
assert!(
!controller.is_html5_active(),
"loading into the native backend hands transport back to it"
);
// The toggle must reach the backend, not be emitted at a dead element.
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert!(
controls.is_empty(),
"transport went to a webview element that is not rendering: {controls:?}"
);
}
// EndReason state machine tests
#[test]
fn test_load_and_play_sets_new_track_loaded() {
+124
View File
@@ -122,6 +122,55 @@ pub fn playback_subtitle_stream_index() -> i32 {
NO_SUBTITLE_STREAM
}
/// Query keys through which a stream URL can carry a subtitle decision.
///
/// Jellyfin binds query keys case-insensitively, so the match has to be too —
/// the server itself mixes casing (`SubtitleStreamIndex` but
/// `alwaysBurnInSubtitleWhenTranscoding`).
const SUBTITLE_QUERY_KEYS: &[&str] = &[
"subtitlestreamindex",
"subtitlemethod",
"subtitlecodec",
"alwaysburninsubtitlewhentranscoding",
];
/// Rewrite a stream URL so it asks for no subtitle, whoever built it.
///
/// [`playback_subtitle_stream_index`] only governs the URLs *this app* builds.
/// When `PlaybackInfo` answers with a `TranscodingUrl`, the URL was built by the
/// server from its own subtitle verdict, and we play it verbatim — so a server
/// that picked a track anyway (a live channel opened without an index, a source
/// whose default is image-based) hands us `SubtitleMethod=Encode`, and the
/// burn-in the negotiation just declined comes back through the URL. Burn-in is
/// a *video* cost: it rules out remuxing and forces a full re-encode.
///
/// Stripping the keys is not enough on its own — an absent index is not "none",
/// it is "you choose" — so the sentinel is always appended.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
pub fn without_server_chosen_subtitle(url: &str) -> String {
let (path, query) = match url.split_once('?') {
Some((path, query)) => (path, query),
None => (url, ""),
};
let mut kept: Vec<&str> = query
.split('&')
.filter(|param| !param.is_empty())
.filter(|param| {
let key = param.split_once('=').map_or(*param, |(key, _)| key);
!SUBTITLE_QUERY_KEYS
.iter()
.any(|subtitle_key| key.eq_ignore_ascii_case(subtitle_key))
})
.collect();
let sentinel = format!("SubtitleStreamIndex={}", NO_SUBTITLE_STREAM);
kept.push(&sentinel);
format!("{}?{}", path, kept.join("&"))
}
/// Whether a subtitle in this format can reach the app as a sidecar it draws
/// itself — the same verdict as [`subtitle_forces_burn_in`], from the reader's
/// side, and the one a subtitle picker needs.
@@ -249,6 +298,81 @@ mod tests {
assert_eq!(playback_subtitle_stream_index(), -1);
}
/// A transcode URL the *server* built carries the server's own subtitle
/// verdict. Adopting it verbatim re-introduces the burn-in
/// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
/// asks for no subtitle, and then we play a URL that asks for one anyway.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn a_server_built_transcode_url_has_its_burn_in_stripped() {
// Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
// `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
&VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
&PlaySessionId=xyz";
let url = without_server_chosen_subtitle(served);
assert!(
url.contains("SubtitleStreamIndex=-1"),
"the adopted URL must ask for no subtitle: {url}"
);
assert!(
!url.contains("SubtitleStreamIndex=2"),
"the server's chosen track must not survive: {url}"
);
assert!(
!url.contains("SubtitleMethod"),
"burn-in must not be requested: {url}"
);
// Everything else identifies the job and must survive untouched.
for kept in [
"DeviceId=jt",
"MediaSourceId=src1",
"VideoCodec=h264",
"PlaySessionId=xyz",
] {
assert!(url.contains(kept), "{kept} must survive: {url}");
}
}
/// The server may also be told to burn in unconditionally
/// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
/// rather than expressed as a method — and its keys are not PascalCase.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
let url = without_server_chosen_subtitle(
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
&subtitlestreamindex=3&SubtitleCodec=ass",
);
assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
assert!(url.contains("api_key=k"), "{url}");
}
/// A URL the server built without any subtitle in it still has to *say* so:
/// omitting the index is what makes the server apply the source's default.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
assert_eq!(
url,
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
);
// A bare URL is rare but must not come out malformed.
let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
}
/// TRACES: UR-020 | DR-176 | UT-168
#[test]
fn text_subtitles_are_advertised_as_external_sidecars() {
+97 -12
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
@@ -1741,7 +1760,16 @@ impl MediaRepository for OnlineRepository {
if let Some(previous) = adopt_video_play_session(response.play_session_id.clone()) {
self.stop_transcode(&previous).await;
}
format!("{}{}", self.server_url, transcoding_url)
// The server built this URL from its *own* subtitle verdict, so it can
// hand back the burn-in the request above just declined. Strip it: the
// negotiated answer only holds for the stream we actually open.
//
// TRACES: UR-020, UR-004 | DR-176 | UT-168
format!(
"{}{}",
self.server_url,
super::device_profile::without_server_chosen_subtitle(transcoding_url)
)
} else if audio_forces_transcode {
warn!(
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
@@ -1844,6 +1872,13 @@ impl MediaRepository for OnlineRepository {
auto_open_live_stream: bool,
is_playback: bool,
max_streaming_bitrate: u64,
/// "No subtitle", for the same reason as everywhere else: omitting it
/// lets the server apply the channel's default track, and broadcast
/// subtitles are DVB bitmaps — deliverable only by burning them in,
/// which forces a full re-encode of a stream that is already tight.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
subtitle_stream_index: i32,
}
#[derive(Debug, Deserialize)]
@@ -1871,6 +1906,7 @@ impl MediaRepository for OnlineRepository {
// too — a channel opened at the source bitrate would walk straight
// past a limit set for the connection. TRACES: UR-074 | DR-162
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
};
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
@@ -1886,14 +1922,21 @@ impl MediaRepository for OnlineRepository {
// The transcoding URL is server-relative; make it absolute. If the server
// did not provide one (rare for live), fall back to the HLS master endpoint.
let stream_url = match source.transcoding_url {
Some(url) => format!("{}{}", self.server_url, url),
// As in `get_playback_info`: the server chose the subtitle in this
// URL, so decline it here too. TRACES: UR-020 | DR-176 | UT-168
Some(url) => format!(
"{}{}",
self.server_url,
super::device_profile::without_server_chosen_subtitle(&url)
),
None => format!(
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
self.server_url,
item_id,
self.access_token,
source.id,
source.live_stream_id.clone().unwrap_or_default(),
super::device_profile::playback_subtitle_stream_index(),
),
};
@@ -3447,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.5.5",
"version": "0.7.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -0,0 +1,347 @@
/**
* VideoPlayer native-path reveal tests (Android / ExoPlayer)
*
* Reproduces "native video plays as audio with no picture" (DR-172).
*
* The poster/title card is an opaque `bg-black` overlay drawn while
* `isMediaReady` is false. Every signal that clears it `canplay`,
* `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event and two
* `readyState` timeouts comes from the HTML5 `<video>` element. On the native
* path there is no such element, so nothing ever cleared it: ExoPlayer decoded
* and fed its SurfaceView correctly the whole time, behind a black div.
*
* These tests pin the **flag-on** path: the backend reports native, the user
* opted in, and the video area must be revealed by the *backend's* own signals.
*
* TRACES: UR-003, UR-004, UR-041 | DR-182 | UT-185
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// The native path is what these tests guard, so the opt-in flag is mocked ON.
// Stated explicitly rather than inherited: the default has moved twice
// (DR-161 on, DR-172 off) and a test that inherits it silently changes meaning.
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
return {
...actual,
experimentalNativeVideo: {
subscribe: (run: (v: boolean) => void) => {
run(true);
return () => {};
},
set: () => {},
current: () => true,
},
};
});
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
const playerPlayItem = vi.fn(async () => ({
// What Android reports: native ExoPlayer backend, no HTML5 element.
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" },
}));
const playerStop = vi.fn(async () => ({}));
const playerReportState = vi.fn(async () => null);
vi.mock("$lib/api/bindings", () => ({
commands: {
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
playerStop: (...a: any[]) => playerStop(...(a as [])),
playerReportState: (...a: any[]) => playerReportState(...(a as [])),
playerReportPosition: vi.fn(async () => null),
playerReportMediaLoaded: vi.fn(async () => null),
playerSeek: vi.fn(async () => ({})),
playerPlay: vi.fn(async () => ({})),
playerPause: vi.fn(async () => ({})),
playerToggle: vi.fn(async () => ({ state: "playing" })),
playerSeekVideo: vi.fn(async (_h: string, position: number) => ({
strategy: "native",
position,
})),
playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: vi.fn(async () => ({})),
playerSetSleepTimer: vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 })),
playerCancelSleepTimer: vi.fn(async () => ({
mode: { kind: "off" },
remainingSeconds: 0,
})),
playerGetStreamingQualities: vi.fn(async () => []),
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
},
events: {
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({
getHandle: () => "repo-1",
getSubtitleUrl: async () => "",
jrayActorsAt: async () => [],
}),
},
}));
vi.mock("$app/navigation", () => ({
goto: vi.fn(),
}));
// The immersive bridge is native-only; assert the call rather than its effect.
const enterImmersive = vi.fn();
vi.mock("$lib/utils/immersive", () => ({
enterImmersive: (...a: any[]) => enterImmersive(...a),
exitImmersive: vi.fn(),
isImmersiveSupported: () => true,
}));
import { render, waitFor } from "@testing-library/svelte";
import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import { player } from "$lib/stores/player";
import type { MediaItem } from "$lib/api/types";
function makeEpisode(): MediaItem {
return {
id: "ep1",
name: "Episode 1",
kind: "episode",
durationMs: 24 * 60 * 1000,
} as MediaItem;
}
async function mountNativePlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
},
});
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
// The native path must NOT be overridden to HTML5 and must NOT be stopped —
// if it were, these tests would be guarding the HTML5 path by accident.
await waitFor(() =>
expect(utils.container.querySelector("video")).toBeNull()
);
expect(playerStop).not.toHaveBeenCalled();
return utils;
}
/** The opaque poster/title card drawn while the media is not yet revealed. */
function poster(container: HTMLElement): HTMLElement | null {
return container.querySelector('[data-testid="video-poster"]');
}
/**
* Report backend playback state the way the app actually does.
*
* NOT via `player://position-update` / `player://state-changed`: those channels
* are **never emitted by the backend**, which is exactly the trap this test
* exists to avoid. An earlier version of it fired those handlers by hand, went
* green, and guarded nothing on the device the poster stayed up while
* ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and
* the store is what the component must read.
*/
async function backendReports(
kind: "playing" | "paused" | "error",
position = 0,
duration = 0
) {
const media = makeEpisode();
if (kind === "playing") player.setPlaying(media, position, duration);
else if (kind === "paused") player.setPaused(media, position, duration);
else player.setError("Decoder failed", media);
await tick();
}
describe("VideoPlayer native path reveals the video (DR-172)", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
player.setIdle();
});
it("keeps the poster up until the backend reports something", async () => {
const { container } = await mountNativePlayer();
// Nothing has been heard from ExoPlayer yet, so the title card is correct.
expect(poster(container)).not.toBeNull();
});
it("clears the poster when the backend reports playing", async () => {
const { container } = await mountNativePlayer();
await backendReports("playing", 0, 1440);
// The surface is rendering behind the webview; an opaque overlay over it is
// exactly the "audio with no picture" defect.
await waitFor(() => expect(poster(container)).toBeNull());
});
it("clears the poster when the backend reports a paused position with a duration", async () => {
const { container } = await mountNativePlayer();
// Backstop for a backend that starts paused: a position carrying a real
// duration means the media is loaded and the surface has content,
// mirroring the HTML5 readyState fallback.
await backendReports("paused", 12, 1440);
await waitFor(() => expect(poster(container)).toBeNull());
});
it("clears the play overlay when the backend resumes after a pause (DR-186)", async () => {
const { container } = await mountNativePlayer();
await backendReports("paused", 5, 1440);
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
);
await backendReports("playing", 6, 1440);
// This overlay is `bg-black/30` across the whole video area: left up, it
// both dims and covers the ExoPlayer surface while it plays. Before the
// mirror, nothing after init could take it down, because the only other
// writer was the never-emitted `player://state-changed` channel.
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
});
it("raises the play overlay again when the backend reports paused (DR-186)", async () => {
const { container } = await mountNativePlayer();
await backendReports("playing", 5, 1440);
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
await backendReports("paused", 6, 1440);
// The mirror has to work in both directions, or pausing leaves no affordance
// to resume.
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
);
});
it("hides the system bars on entry, not only on the fullscreen button (DR-187)", async () => {
await mountNativePlayer();
// The player owns the whole screen; on the native path the system bars would
// otherwise sit directly on top of the ExoPlayer surface.
expect(enterImmersive).toHaveBeenCalled();
});
it("hides the control bar once playback starts, however late (DR-189)", async () => {
// Reproduce the device sequence: the backend is still starting when the
// player mounts, so playback begins *after* the first countdown window.
playerPlayItem.mockResolvedValueOnce({
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "loading" },
} as any);
vi.useFakeTimers();
try {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
},
});
await vi.advanceTimersByTimeAsync(50);
// The three seconds after entry elapse while the backend is still
// starting, so the bar correctly stays up. This is the exact window that
// defeated the first attempt: a one-shot timer armed on entry fired here,
// declined, and was never re-armed.
await vi.advanceTimersByTimeAsync(3500);
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain("opacity-0");
// Playback starts late; the countdown has to restart on its own.
player.setPlaying(makeEpisode(), 5, 1440);
await vi.advanceTimersByTimeAsync(3500);
await vi.waitFor(() =>
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain("opacity-0")
);
} finally {
vi.useRealTimers();
}
});
it("never reports webview element state on the native path (DR-195)", async () => {
// The report that mattered came from the 10-second progress interval, so
// the test has to reach it: the interval needs `onReportProgress` wired and
// `isPlaying` true, then time has to pass. Asserting on a freshly mounted
// player proves nothing — an earlier version of this test did exactly that
// and passed with the guard deleted.
vi.useFakeTimers();
try {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
onReportProgress: vi.fn(),
},
});
await vi.advanceTimersByTimeAsync(100);
expect(utils.container.querySelector("video")).toBeNull();
// Backend playing, so the interval's `isPlaying` guard is satisfied.
player.setPlaying(makeEpisode(), 5, 1440);
await vi.advanceTimersByTimeAsync(25_000);
// `html5_playing` is Rust's record of "a webview element is active", and
// `toggle_playback`/`play`/`pause` all route transport to that element
// whenever it is set. Reporting it with no element in existence is what
// left the pause button dead on the native path — from the surface tap,
// the control bar, and a direct `player_toggle` invocation alike — while
// seek and skip kept working, because they decide elsewhere.
expect(playerReportState).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("does not clear the poster on an errored backend", async () => {
const { container } = await mountNativePlayer();
await backendReports("error");
// Revealing here would replace the title card with a transparent hole
// showing the launcher through the app.
expect(poster(container)).not.toBeNull();
});
});
+217 -13
View File
@@ -25,7 +25,7 @@
type RenderableSubtitleTrack,
} from "./subtitleTracks";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition, playerState } from "$lib/stores/player";
import { playbackPosition, playbackDuration, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player";
import {
@@ -40,6 +40,8 @@
enableNativeVideoCompositing,
disableNativeVideoCompositing,
} from "$lib/utils/videoSurface";
import { nativeSignalRevealsVideo } from "./mediaReady";
import { shouldHideControls } from "./controlsVisibility";
import {
isPipSupported,
enterPip,
@@ -70,6 +72,7 @@
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
shouldResumeOnForeground,
planHandoffReturn,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
@@ -154,7 +157,9 @@
let pipListenerCleanup: (() => void) | null = null;
let showSleepTimerModal = $state(false);
let isBuffering = $state(false);
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
// Bumped by every reveal so the auto-hide effect restarts its countdown even
// when no other input to that decision changed (a tap during playback).
let lastControlsInteraction = $state(0);
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
let isSeeking = $state(false);
// Capture only the initial streamUrl prop; later prop changes are applied via
@@ -441,6 +446,88 @@
}
});
// Auto-hide the control bar.
//
// An `$effect` rather than a timer armed by input, because the conditions that
// *permit* hiding arrive on their own schedule. The first attempt armed a
// one-shot timer from `revealControls()` on entry; three seconds later
// playback had not started yet, `shouldHideControls` correctly declined, and
// nothing re-armed it — so the bar sat over the video for the whole film. The
// timer has to follow the state, not the input event.
//
// Re-runs whenever any input changes: each run cancels the previous timer, so
// starting playback, closing a menu or finishing a seek re-arms it, and
// pausing or opening a menu cancels it. `lastControlsInteraction` is read so a
// tap restarts the countdown even when nothing else changed.
//
// TRACES: UR-003, UR-066 | DR-189 | UT-188
$effect(() => {
void lastControlsInteraction;
if (!showControls) return;
if (
!shouldHideControls({
isPlaying,
isSeeking,
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
})
) {
return;
}
const timer = setTimeout(() => {
showControls = false;
}, 3000);
return () => clearTimeout(timer);
});
// Reveal the video on the native path.
//
// The poster/title card is opaque and covers the whole video area, so on this
// path it is the only thing between the viewer and the ExoPlayer surface —
// every other markMediaReady() call site is a `<video>` element event, and
// there is no `<video>` here.
//
// Driven from the same stores as the seek bar above, deliberately: the
// `player://position-update` and `player://state-changed` channels the native
// branch subscribes to are **never emitted by the backend** (see the comment
// on the effect above — the seek bar had to be moved off them for the same
// reason). Hooking the reveal to those channels looks right, passes a test
// that fires them by hand, and does nothing on a device.
//
// TRACES: UR-003, UR-004 | DR-182 | UT-185
$effect(() => {
if (useHtml5Element || isMediaReady) return;
const state = $playerState.kind;
const position = $playbackPosition;
const duration = $playbackDuration;
if (
nativeSignalRevealsVideo({ kind: "state", state }) ||
nativeSignalRevealsVideo({ kind: "position", position, duration })
) {
markMediaReady();
}
});
// Mirror the backend's play/pause into the UI on the native path.
//
// `isPlaying` is assigned once from the player_play_item response and then
// only by the `player://state-changed` listener — a channel the backend never
// emits, exactly as for the reveal above. So on the native path it was
// whatever the initial response said, forever: with ExoPlayer playing, the UI
// still believed it was paused, which raised the `bg-black/30` play overlay
// over the video surface and left the transport button showing ▶. The video
// was both dimmed and covered while it played.
//
// The player is the authoritative source of playback state and the UI is a
// consumer of it (see the architecture docs), so this reads the same store
// `playerEvents.ts` feeds rather than tracking it locally. HTML5 keeps its own
// element-event wiring, which is authoritative for that path.
//
// TRACES: UR-003, UR-005 | DR-186 | UT-187
$effect(() => {
if (useHtml5Element) return;
isPlaying = $playerState.kind === "playing";
});
// Set up HLS.js for HLS streams
$effect(() => {
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
@@ -695,6 +782,21 @@
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
}
// The video player owns the whole screen, so the system bars go away with it
// — not only when the fullscreen button is pressed, which was the sole
// caller of enterImmersive(). The status and navigation bars stayed painted
// over the player on entry, and on the native path they sit directly on top
// of the ExoPlayer surface, which fills the content view.
//
// Synchronous, before any await, per the native-mode pitfall above. Paired
// with the unconditional exitImmersive() in onDestroy. (UR-066, DR-187)
enterImmersive();
// Arm the control-bar auto-hide on entry. Without this the bar only ever
// hides after the first pointer/touch event, which on a touchscreen meant
// "after the user happens to tap" — and before DR-189 wired touch up, never.
revealControls();
// Initialize player via Rust - Rust will decide which backend to use based on platform
if (media && currentStreamUrl) {
try {
@@ -915,6 +1017,7 @@
progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false, reportMediaId);
mirrorElementStateToRust(false);
}
}, 10000);
}
@@ -1348,6 +1451,33 @@
}
});
/**
* Mirror the **webview element's** play/pause and position into Rust.
*
* Only ever when the element is what renders. `html5_playing` is Rust's record
* of "a webview element is active and in this state", and `toggle_playback`,
* `play` and `pause` all route transport to that element when it is set. So
* reporting it from the native path is not a harmless extra: it hands
* transport authority to an element that does not exist, and every play/pause
* intent is then emitted into the void. That is exactly what made the pause
* button dead on the native path — from the on-screen tap, the control bar,
* and even a direct `player_toggle` invocation — while seek and skip kept
* working, because they decide elsewhere.
*
* This lived in the player route's reporting callbacks, which cannot tell the
* two rendering paths apart and so mirrored unconditionally — including from
* the 10-second progress interval, which is why the flag came back after
* DR-193 cleared it at load. It belongs here, where `useHtml5Element` is
* known.
*
* TRACES: UR-005, UR-003 | DR-195 | UT-189
*/
function mirrorElementStateToRust(paused: boolean) {
if (!useHtml5Element) return;
html5Adapter.reportState(paused ? "paused" : "playing", reportMediaId ?? null);
html5Adapter.reportPosition(currentTime, duration, { force: true });
}
function handlePlay() {
isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates
@@ -1607,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;
@@ -1632,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
@@ -1676,18 +1865,25 @@
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleMouseMove() {
/**
* Show the control bar and arm its auto-hide.
*
* This used to be `handleMouseMove` and was wired *only* to the container's
* `onmousemove`. A touchscreen never fires `mousemove`, so on Android the
* timer was never armed and the bar stayed up for the whole film — hidden in
* plain sight while the native video surface was itself invisible. It is now
* armed on entry and on every touch interaction as well.
*
* TRACES: UR-003, UR-066 | DR-189 | UT-188
*/
function revealControls() {
showControls = true;
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
lastControlsInteraction = Date.now();
}
// Kept as the mouse entry point; desktop still drives it from pointer motion.
const handleMouseMove = revealControls;
async function seekRelative(seconds: number) {
isSeeking = true;
@@ -1850,6 +2046,10 @@
playerGestureActive = false;
swipeGestureActive = false;
swipeType = null;
// Touch is the only input on the platform this player mostly runs on, and
// it is what `mousemove` never covers: show the bar and re-arm its hide.
// (DR-189)
revealControls();
}
/**
@@ -2117,7 +2317,10 @@
<!-- Title card with loading spinner (Loading state from DR-001) -->
{#if !isMediaReady}
<div class="absolute inset-0 flex items-center justify-center bg-black">
<div
data-testid="video-poster"
class="absolute inset-0 flex items-center justify-center bg-black"
>
<!-- Poster/Title Card -->
{#if media?.imageId}
<CachedImage
@@ -2196,6 +2399,7 @@
See DR-098. -->
<button
data-player-surface
data-testid="play-overlay"
class="absolute inset-0 flex items-center justify-center bg-black/30"
onclick={handleSurfaceClick}
aria-label="Play"
@@ -32,6 +32,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/svelte";
import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
@@ -128,6 +129,23 @@ function renderPlayer() {
describe("VideoPlayer tap surface (real component)", () => {
beforeEach(() => {
vi.clearAllMocks();
// This file deliberately does NOT mock `$lib/api/bindings` — it renders the
// real component against the real bindings, which bottom out in the globally
// mocked `invoke`. That mock resolves `undefined` for every command, so the
// commands whose results are *rendered* have to be answered here: the
// quality picker assigns the result straight to state and then does
// `streamingQualities.length` in the template, which throws (asynchronously,
// outside any test) on undefined and fails the run with an unhandled error.
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
switch (cmd) {
case "player_get_streaming_qualities":
return [];
case "player_get_video_settings":
return { streamingQuality: "original" };
default:
return undefined;
}
});
});
it("a single tap on the video toggles play/pause exactly once", async () => {
@@ -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),
};
}
@@ -0,0 +1,31 @@
/**
* Control-bar auto-hide rule (DR-189).
*
* TRACES: UR-003, UR-066 | DR-189 | UT-188
*/
import { describe, it, expect } from "vitest";
import { shouldHideControls } from "./controlsVisibility";
const playing = { isPlaying: true, isSeeking: false, menuOpen: false };
describe("shouldHideControls", () => {
it("hides the bar during uninterrupted playback", () => {
expect(shouldHideControls(playing)).toBe(true);
});
it("keeps the bar while paused", () => {
// A user who paused by tapping the surface has no other way back.
expect(shouldHideControls({ ...playing, isPlaying: false })).toBe(false);
});
it("keeps the bar while seeking", () => {
// The position readout is the point of the bar mid-seek.
expect(shouldHideControls({ ...playing, isSeeking: true })).toBe(false);
});
it("keeps the bar while a menu is open", () => {
// The menus are anchored to the bar; hiding it takes the open menu with it.
expect(shouldHideControls({ ...playing, menuOpen: true })).toBe(false);
});
});
@@ -0,0 +1,40 @@
/**
* When the player's control bar may auto-hide.
*
* TRACES: UR-003, UR-066 | DR-189 | UT-188
*
* The bar's hide timer used to be armed from exactly one place — the container's
* `onmousemove`. A touchscreen never fires `mousemove`, so on Android the timer
* was never set and the bar stayed on screen for the whole film. It went
* unnoticed while the video itself was invisible: with nothing to obscure, a
* permanent control bar looks like the UI, not like a defect.
*
* The decision is separated from the timer so it can be tested without a clock
* or a DOM: it is a rule about state, and the parts that were wrong here were
* the conditions, not the `setTimeout`.
*/
/** Everything that decides whether the bar may disappear right now. */
export interface ControlsHideContext {
/** Hiding controls over a paused player strands the user with no affordance. */
isPlaying: boolean;
/** A seek in flight is exactly when the position readout is worth watching. */
isSeeking: boolean;
/** True while any of the track / subtitle / quality menus is open. */
menuOpen: boolean;
}
/**
* Whether the control bar may hide now.
*
* Requires playback to be running: a paused player keeps its controls, which is
* both the convention and the only way back for a user who paused by tapping.
* A menu open over the bar pins it too the menus are anchored to the bar, so
* hiding it would take the open menu with it, mid-interaction.
*/
export function shouldHideControls(ctx: ControlsHideContext): boolean {
if (!ctx.isPlaying) return false;
if (ctx.isSeeking) return false;
if (ctx.menuOpen) return false;
return true;
}
@@ -0,0 +1,49 @@
/**
* Native-path reveal rule (DR-182).
*
* TRACES: UR-003, UR-004 | DR-182 | UT-184
*/
import { describe, it, expect } from "vitest";
import { nativeSignalRevealsVideo } from "./mediaReady";
describe("nativeSignalRevealsVideo", () => {
it("reveals on the backend's playing state", () => {
expect(nativeSignalRevealsVideo({ kind: "state", state: "playing" })).toBe(true);
});
it.each(["buffering", "paused", "stopped", "ended", "error", "idle", ""])(
"leaves the poster up on state %s",
(state) => {
expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false);
}
);
it("reveals on a position tick that carries a duration", () => {
expect(
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })
).toBe(true);
});
it("reveals on a position tick that has advanced, even with no duration", () => {
// Live streams report no duration; an advancing position is still proof
// that the surface has content.
expect(
nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })
).toBe(true);
});
it("leaves the poster up on an empty position tick", () => {
// A tick before anything is loaded proves nothing, and revealing here would
// show a transparent hole through the app.
expect(
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })
).toBe(false);
});
it("does not treat a negative position as progress", () => {
expect(
nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })
).toBe(false);
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* When the video area may be revealed on the **native** (ExoPlayer) path.
*
* TRACES: UR-003, UR-004 | DR-182 | UT-184
*
* VideoPlayer draws an opaque `bg-black` poster/title card over the video area
* until `isMediaReady`. Every signal that clears it is emitted by the HTML5
* `<video>` element `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the
* `playing` event, and two `readyState` timeouts. The native path has no such
* element, so on Android nothing ever cleared the card: ExoPlayer decoded to a
* live SurfaceView behind a black div, which is the "audio with no picture"
* report of DR-172 and is indistinguishable from a compositing failure.
*
* The backend's own events are the equivalent signals, and this is the rule for
* reading them. It is a pure function rather than a branch inside the component
* because the component cannot be exercised without a DOM and a mounted player,
* and this decision is exactly the part that was missing and needs a guard.
*/
/** A player event that might mean "the surface has a picture on it". */
export type NativeRevealSignal =
| { kind: "state"; state: string }
| { kind: "position"; position: number; duration: number };
/**
* Whether `signal` proves the native backend is rendering, and the poster card
* should therefore come down.
*
* Two signals qualify, mirroring the HTML5 path's primary event and its
* backstop:
*
* - **`state === "playing"`** the direct equivalent of the `<video>`
* `playing` event. ExoPlayer reports this once it is actually drawing.
* - **a position tick carrying a real position or duration** the equivalent
* of the `readyState` fallbacks. It covers a first state event that is
* dropped or arrives before the listener is attached; a tick means the media
* is loaded and the surface has content.
*
* Everything else `buffering`, `paused`, `stopped`, `error` leaves the card
* up. Revealing on `error` in particular would replace the title card with a
* transparent hole showing the launcher through the app.
*/
export function nativeSignalRevealsVideo(signal: NativeRevealSignal): boolean {
if (signal.kind === "state") {
return signal.state === "playing";
}
return signal.duration > 0 || signal.position > 0;
}
+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");
});
});
+50 -20
View File
@@ -27,32 +27,60 @@ const STORAGE_KEY = "jellytau-experimental-native-video";
const NATIVE_VIDEO_ATTR = "data-native-video";
/**
* Whether the native path is on. **Off** unless the user turned it on.
* Whether the native path is on, defaulting to **on** when the user has never
* chosen.
*
* DR-161 briefly made this default to on, so picture-in-picture could shrink a
* real video surface. On a device that shipped as **audio with no picture**:
* ExoPlayer decoded correctly and fed its SurfaceView, but the SurfaceView sits
* *behind* the WebView and the compositing that clears the opaque layers above it
* never took effect logcat showed `WebView transparent = false` and never
* `= true`. So the video was rendering the whole time, behind the page.
* This default has moved three times, so the history is the documentation:
*
* That is the defect the flag existed to contain, and it is why the default is
* back off: video working matters more than PiP showing the native surface, and
* PiP still works without it via the HTML5 path (DR-160). Native video remains
* available in Settings for anyone testing it.
* - **off** while the path was a spike (DR-150).
* - **on** for picture-in-picture (DR-161), which shipped as *audio with no
* picture* ExoPlayer decoded correctly into a live SurfaceView while the
* page stayed opaque over it.
* - **off** again (DR-172), which named the compositing as the suspect but did
* not find it.
* - **on** now, because the four defects behind that symptom were found and
* each is fixed and verified on a device: the app shell painted over the
* surface through a CSS rule targeting an attribute nothing set (DR-185); the
* poster card had no way to lift on a path with no `<video>` element
* (DR-182); the JS bridges raced the page load, so `setTransparent(true)`
* could never arrive (DR-183); and the SurfaceView was never detached
* (DR-184). Two further UI defects that only this path could show the play
* overlay never clearing (DR-186) and the system bars staying over the player
* (DR-187) are fixed with it.
*
* 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 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).
*
* - **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-172
* 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 safe (HTML5) path.
return false;
// Private-mode / disabled storage — same default as a fresh install.
return true;
}
}
@@ -80,9 +108,11 @@ function createExperimentalNativeVideoStore() {
}
/**
* User opt-in for the native Android video path. **Defaults to off** again since
* DR-172 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.
+91
View File
@@ -0,0 +1,91 @@
/**
* Every opaque layer the native-video CSS claims to clear must actually exist.
*
* TRACES: UR-003, UR-004 | DR-185 | UT-186
*
* The compositing rules in app.css clear the page's painted backgrounds so the
* ExoPlayer SurfaceView behind the WebView can be seen. One of the three
* selectors, `[data-app-shell]`, was written against an attribute that **no
* component ever set** in any commit so the app shell went on painting
* `--color-background` across the whole viewport, underneath a player that had
* correctly made itself transparent. The WebView therefore composited opaque
* and the surface could never show through.
*
* That failure is invisible three ways over: the CSS is valid, the selector is
* plausible, and the symptom (black screen, audio fine) is identical to a
* genuine compositing failure which is how it survived DR-150 through DR-172.
* A rule that matches nothing is the specific defect worth a tripwire, so this
* asserts the relationship rather than the rule: every attribute the block
* targets is set somewhere in the app.
*/
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const srcRoot = path.resolve(here, "../..");
function read(file: string): string {
return fs.readFileSync(file, "utf-8");
}
/** Every .svelte file under src/. */
function svelteFiles(dir: string, found: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) svelteFiles(full, found);
else if (entry.name.endsWith(".svelte")) found.push(full);
}
return found;
}
/**
* The selector list of the `[data-native-video="active"]` rule in app.css.
* Returned verbatim, one selector per entry.
*/
function compositingSelectors(css: string): string[] {
const marker = 'html[data-native-video="active"]';
const start = css.indexOf(marker);
expect(start, "app.css no longer contains the native-video rule").toBeGreaterThan(-1);
const open = css.indexOf("{", start);
return css
.slice(start, open)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
describe("native-video compositing layers (DR-185)", () => {
const css = read(path.join(srcRoot, "app.css"));
const selectors = compositingSelectors(css);
const markup = svelteFiles(srcRoot).map(read).join("\n");
it("clears the app shell, which paints over the whole viewport", () => {
// The shell is the layer directly between the player and the WebView; if it
// stays painted, nothing below it can be seen however transparent the
// player and the WebView widget are.
expect(selectors.some((s) => s.includes("[data-app-shell]"))).toBe(true);
expect(markup).toContain("data-app-shell");
});
it("targets no attribute that nothing in the app sets", () => {
const attributes = selectors
.flatMap((selector) => [...selector.matchAll(/\[([a-zA-Z-]+)(?:[=\]])/g)])
.map((match) => match[1])
// data-native-video is set imperatively on <html> by nativeVideo.ts, not
// in markup, so it is verified against that module instead.
.filter((attr) => attr !== "data-native-video");
const unset = [...new Set(attributes)].filter((attr) => !markup.includes(attr));
expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`)
.toEqual([]);
});
it("still sets data-native-video on <html> from the store", () => {
const store = read(path.join(srcRoot, "lib/stores/nativeVideo.ts"));
expect(store).toContain("data-native-video");
expect(store).toContain("documentElement");
});
});
+17 -2
View File
@@ -1,7 +1,7 @@
/**
* Native video surface compositing, Android only.
*
* TRACES: UR-003, UR-004 | DR-150, DR-151
* TRACES: UR-003, UR-004 | DR-150, DR-151, DR-183
*
* On Android, ExoPlayer renders video into a SurfaceView that sits *behind* the
* Tauri WebView (`setZOrderMediaOverlay(false)`, added at index 0 of the content
@@ -65,8 +65,23 @@ export function enableNativeVideoCompositing(): void {
// Page layer first: if the Kotlin call succeeded but this threw, the user
// would see through the app to the home screen.
nativeVideoActive.set(true);
const androidVideoSurface = bridge();
if (!androidVideoSurface) {
// Say so loudly. Every bridge call in this file is optional-chained, so a
// missing bridge is silent — and a silently-skipped setTransparent(true) is
// indistinguishable on screen from a compositing failure: ExoPlayer renders
// correctly behind a WebView that never stopped painting its own opaque
// background. That ambiguity is what DR-172 was left holding. MainActivity's
// console bridge forwards this to logcat under the JellyTauWeb tag.
console.error(
"[videoSurface] AndroidVideoSurface bridge is MISSING - the webview will " +
"stay opaque and native video will play as audio with no picture"
);
return;
}
try {
bridge()?.setTransparent(true);
androidVideoSurface.setTransparent(true);
console.log("[videoSurface] compositing enabled (setTransparent(true) sent)");
} catch (err) {
console.warn("[videoSurface] setTransparent(true) failed:", err);
nativeVideoActive.set(false);
+9
View File
@@ -260,7 +260,16 @@
TRACES: UR-066 | DR-112
-->
<!--
data-app-shell marks the layer app.css clears for native video. This div
paints --color-background across the entire viewport, *under* a VideoPlayer
that makes itself transparent on the native path — so while it stays painted,
the ExoPlayer SurfaceView behind the WebView cannot be seen no matter what
else is cleared. The rule in app.css was written for this attribute; the
attribute was never added. (DR-185)
-->
<div
data-app-shell
class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col
pt-[var(--safe-top)] pl-[var(--safe-left)] pr-[var(--safe-right)]"
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
+42 -8
View File
@@ -7,6 +7,7 @@
let serverName = $state("");
let username = $state("");
let password = $state("");
let showPassword = $state(false);
let connecting = $state(false);
let loggingIn = $state(false);
let localError = $state<string | null>(null);
@@ -146,6 +147,10 @@
type="text"
bind:value={username}
placeholder="Enter your username"
autocapitalize="none"
autocorrect="off"
autocomplete="username"
spellcheck="false"
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
disabled={loggingIn}
/>
@@ -155,14 +160,43 @@
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="password"
type="password"
bind:value={password}
placeholder="Enter your password"
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
disabled={loggingIn}
/>
<div class="relative">
<!-- `type` is dynamic, so bind:value is not allowed here (Svelte); wire it manually. -->
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
oninput={(e) => (password = e.currentTarget.value)}
placeholder="Enter your password"
autocapitalize="none"
autocorrect="off"
autocomplete="current-password"
spellcheck="false"
class="w-full pl-4 pr-12 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
disabled={loggingIn}
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
disabled={loggingIn}
aria-label={showPassword ? "Hide password" : "Show password"}
aria-pressed={showPassword}
class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-white disabled:opacity-50 focus:outline-none focus:text-white"
>
{#if showPassword}
<!-- eye-off -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
{:else}
<!-- eye -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{/if}
</button>
</div>
</div>
{#if localError || $authError}
+6 -8
View File
@@ -561,11 +561,11 @@
if (id) {
reportPlaybackStart(id, positionSeconds, context.type, context.id);
}
// Mirror HTML5 <video> state into the Rust PlayerController so it is the
// single source of truth for video playback (see html5Adapter.ts). The
// element lives in the webview and Rust cannot observe it directly.
html5Adapter.reportState("playing", id ?? null);
html5Adapter.reportPosition(positionSeconds, get(playbackDuration), { force: true });
// The element's state is mirrored into Rust by VideoPlayer, which is the
// only place that knows whether a webview element is rendering at all.
// Doing it here mirrored unconditionally, so on the native path it told Rust
// a `<video>` was playing when none existed and transport was then aimed at
// it — see mirrorElementStateToRust in VideoPlayer.svelte (DR-195).
}
function handleReportProgress(positionSeconds: number, isPaused: boolean, reportId?: string) {
@@ -573,9 +573,7 @@
if (id) {
reportPlaybackProgress(id, positionSeconds, isPaused);
}
// Feed the Rust controller the current position and play/pause state.
html5Adapter.reportState(isPaused ? "paused" : "playing", id ?? null);
html5Adapter.reportPosition(positionSeconds, get(playbackDuration), { force: true });
// Element state is mirrored by VideoPlayer (DR-195) — see handleReportStart.
}
function handleReportStop(positionSeconds: number, reportId?: string) {
+4 -4
View File
@@ -744,10 +744,10 @@
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the
built-in web player. Better performance and battery life in
principle, but incomplete: on some devices the picture does not
appear at all and only the sound plays. Leave this off unless
you are helping test it.
built-in web player, for better performance and battery life,
and so picture-in-picture shows the video rather than the app.
On by default. Turn it off to fall back to the built-in web
player if a video misbehaves.
</p>
</div>
<button