Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e98e1c37a | ||
|
|
440d7a01a9 | ||
|
|
dccb5f53dd | ||
|
|
c142568230 | ||
|
|
95129d04a3 | ||
|
|
f0f98feae8 | ||
|
|
d9e1e256e9 | ||
|
|
42868fc2e6 |
@@ -9,6 +9,65 @@ 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.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
|
||||
|
||||
+24
-6
@@ -340,6 +340,19 @@ 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: returning from background audio is HTML5-only (DR-190), so on the native path playback simply stays dead. Shipping it would have repeated DR-161 exactly — a verified sub-path made default over an unverified one — so the default stays off and the flip is gated on DR-190 rather than on more confidence | UI | UR-003, UR-004, UR-041 | Blocked by DR-190 |
|
||||
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
|
||||
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
|
||||
| DR-194 | The previous frame flashing on rotation. It reads as a TextureView artefact — the view retains its last frame, so between a rotation and `fitSurfaceToScreen()` landing that frame sits at the old size — and two fixes were built on that reading: revealing after two `postOnAnimation` hops, then revealing on `onSurfaceTextureUpdated`, which required owning the `SurfaceTextureListener` and handing ExoPlayer the Surface directly rather than via `setVideoTextureView`. **Neither stopped the flash.** The mechanism is the *window's* rotation animation: Android cross-fades a **screenshot of the old orientation**, that screenshot contains the old video frame at the old size, and no TextureView bookkeeping can reach it — nor can the app pre-empt the screenshot, since `onConfigurationChanged` fires after it is taken. The only lever is to stop the animation: `ROTATION_ANIMATION_JUMPCUT`. That was accepted and silently ignored at first, and the platform said why out loud — `VRI[MainActivity]: setLayoutParams: not fullscreen` — because the attribute is honoured only for a fullscreen window. `FLAG_FULLSCREEN` (deprecated for hiding system bars, which immersive mode does instead, but still what marks the window fullscreen for this decision) is therefore set alongside it, scoped to while native compositing is active so the rest of the app keeps its normal animation. The frame-arrival reveal is kept: it replaced a fixed-timeout guess with a real signal, and its timeout is required rather than defensive, since a resize while paused means no new frame is ever coming. **The flash is not confirmed fixed on device** — the forced-rotation harness (`settings put system user_rotation`) proved unreliable, and `screenrecord` fixes its canvas at start so a rotation inside a recording never changes frame dimensions, which defeated two attempts at measuring it | Android | UR-003, UR-066 | Needs device verification |
|
||||
| DR-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 +374,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 |
|
||||
| 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 +411,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 |
|
||||
| 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 +436,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 +632,11 @@ 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 |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4991
-4683
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.5.5",
|
||||
"version": "0.6.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
|
||||
@@ -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.DR).toBe(185);
|
||||
expect(defined.JA).toBe(35);
|
||||
expect(defined.total).toBe(314);
|
||||
expect(defined.total).toBe(327);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.5.5"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.5.5"
|
||||
version = "0.6.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)
|
||||
@@ -154,6 +191,37 @@ class MainActivity : TauriActivity() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotation (and any other config change this Activity handles itself).
|
||||
*
|
||||
* Two things have to happen here rather than later, and both are about the
|
||||
* *previous* video frame surviving the transition:
|
||||
*
|
||||
* - The video view is hidden until a new frame arrives. The equivalent call
|
||||
* in `fitSurfaceToScreen` runs from the content view's layout listener,
|
||||
* which is after the rotation — by then the stale frame has been on screen
|
||||
* for the whole transition.
|
||||
* - The window's rotation animation is a **cross-fade of a screenshot** of
|
||||
* the old orientation, and that screenshot contains the old video frame at
|
||||
* the old size. No amount of TextureView bookkeeping can touch it, which is
|
||||
* why hiding on frame-arrival alone did not stop the flash. `JUMPCUT` drops
|
||||
* the cross-fade, so there is no old frame to fade through; it is set only
|
||||
* while native compositing is active (see setTransparent) so the rest of
|
||||
* the app keeps the normal animation.
|
||||
*
|
||||
* TRACES: UR-003, UR-066 | DR-194
|
||||
*/
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
try {
|
||||
if (com.dtourolle.jellytau.player.JellyTauPlayer.isInitialized()) {
|
||||
com.dtourolle.jellytau.player.JellyTauPlayer.getInstance().hideUntilFreshFrame()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("MainActivity", "hideUntilFreshFrame on config change failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: android.content.res.Configuration
|
||||
@@ -165,7 +233,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 +253,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
|
||||
@@ -328,6 +412,34 @@ class MainActivity : TauriActivity() {
|
||||
window.setBackgroundDrawable(
|
||||
android.graphics.drawable.ColorDrawable(color)
|
||||
)
|
||||
// Drop the rotation cross-fade while a native video surface is
|
||||
// composited behind the page. The animation fades a *screenshot* of
|
||||
// the old orientation, which still holds the previous video frame at
|
||||
// the old size — that is the "previous frame flashing in the black
|
||||
// bars", and it lives in the window animation rather than in
|
||||
// anything the TextureView owns. (DR-194)
|
||||
val attrs = window.attributes
|
||||
attrs.rotationAnimation = if (transparent) {
|
||||
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
|
||||
} else {
|
||||
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_ROTATE
|
||||
}
|
||||
window.attributes = attrs
|
||||
// `rotationAnimation` is honoured only for a **fullscreen** window —
|
||||
// the platform says so out loud, logging
|
||||
// "VRI[MainActivity]: setLayoutParams: not fullscreen" when the
|
||||
// attribute is set on ours, and then animating normally regardless.
|
||||
// Without this the JUMPCUT above is accepted and ignored, and the
|
||||
// cross-fade keeps showing the old orientation's screenshot, stale
|
||||
// video frame and all. FLAG_FULLSCREEN is deprecated for *hiding
|
||||
// system bars* (immersive mode does that, on player entry), but it
|
||||
// is still what marks the window fullscreen for this decision.
|
||||
@Suppress("DEPRECATION")
|
||||
if (transparent) {
|
||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
} else {
|
||||
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
}
|
||||
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
||||
}
|
||||
}
|
||||
@@ -371,10 +483,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
|
||||
@@ -39,6 +38,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** AudioEffect priority. Positive = higher priority than the default. */
|
||||
private const val EFFECT_PRIORITY = 1000
|
||||
|
||||
/**
|
||||
* How long to wait for a fresh frame after a resize before revealing the
|
||||
* view anyway. Playback may be paused, in which case no frame is coming.
|
||||
*/
|
||||
private const val FRESH_FRAME_TIMEOUT_MS = 400L
|
||||
|
||||
/**
|
||||
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
|
||||
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
|
||||
@@ -225,9 +230,18 @@ 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
|
||||
|
||||
/**
|
||||
* True while the view is hidden waiting for a new frame after a resize.
|
||||
* See fitSurfaceToScreen (DR-194).
|
||||
*/
|
||||
@Volatile
|
||||
private var awaitingFreshFrame = false
|
||||
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
@@ -1078,52 +1092,114 @@ 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 and leaves us blind to frame
|
||||
// arrival. `onSurfaceTextureUpdated` is the only honest signal
|
||||
// that a NEW frame has landed in the texture, and that is
|
||||
// precisely what the letterbox artefact waits on — see
|
||||
// fitSurfaceToScreen. Handing ExoPlayer the Surface directly is
|
||||
// the same wiring `setVideoTextureView` does internally.
|
||||
//
|
||||
// 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
|
||||
) {
|
||||
// A genuinely new frame is now in the texture, so
|
||||
// whatever was retained from before the resize is gone.
|
||||
if (awaitingFreshFrame) {
|
||||
awaitingFreshFrame = false
|
||||
videoView?.alpha = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 +1214,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")
|
||||
}
|
||||
@@ -1182,9 +1258,34 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
|
||||
* video to the surface bounds, which crops the bottom on rotation.
|
||||
*/
|
||||
/**
|
||||
* Hide the video view now, and keep it hidden until a genuinely new frame
|
||||
* arrives (or the timeout fires).
|
||||
*
|
||||
* Called from `MainActivity.onConfigurationChanged`, i.e. at the *start* of a
|
||||
* rotation. [fitSurfaceToScreen] is too late for this: it runs from the
|
||||
* content view's layout listener, after the rotation has already happened,
|
||||
* so the stale frame has been on screen for the whole transition by then.
|
||||
*
|
||||
* TRACES: UR-003, UR-066 | DR-194
|
||||
*/
|
||||
fun hideUntilFreshFrame() {
|
||||
mainHandler.post {
|
||||
val view = videoView ?: return@post
|
||||
awaitingFreshFrame = true
|
||||
view.alpha = 0f
|
||||
mainHandler.postDelayed({
|
||||
if (awaitingFreshFrame) {
|
||||
awaitingFreshFrame = false
|
||||
videoView?.alpha = 1f
|
||||
}
|
||||
}, FRESH_FRAME_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun fitSurfaceToScreen() {
|
||||
mainHandler.post {
|
||||
val view = 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 +1317,44 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
if (lp is FrameLayout.LayoutParams) {
|
||||
lp.gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
|
||||
// Hide the view across a resize, and reveal it when a genuinely NEW
|
||||
// video frame lands in the texture.
|
||||
//
|
||||
// A TextureView retains its last frame. Between a rotation and this
|
||||
// re-fit landing, that retained frame is stretched across the OLD
|
||||
// rect — larger than the new one along at least one axis — so the
|
||||
// previous frame flashes in what should be the letterbox bars.
|
||||
//
|
||||
// Waiting a fixed number of animation frames does NOT fix it, which
|
||||
// the first attempt at this proved on device: an animation frame is
|
||||
// not a video frame, and at 24fps the next decoded frame can be
|
||||
// several vsyncs away. The tell was that pausing and playing cleared
|
||||
// the artefact by hand — that forces a fresh frame, which is the
|
||||
// real precondition. So the reveal is driven by
|
||||
// `onSurfaceTextureUpdated` instead.
|
||||
//
|
||||
// The timeout is not belt-and-braces, it is required: if playback is
|
||||
// paused when the resize happens, no new frame is coming and the
|
||||
// video would stay invisible forever. Revealing a stale frame after
|
||||
// a beat is strictly better than a permanently black player.
|
||||
//
|
||||
// Scoped to an actual size change so steady-state playback never
|
||||
// touches alpha.
|
||||
//
|
||||
// TRACES: UR-003, UR-066 | DR-194
|
||||
val sizeChanged = lp.width != targetW || lp.height != targetH
|
||||
if (sizeChanged) {
|
||||
awaitingFreshFrame = true
|
||||
view.alpha = 0f
|
||||
mainHandler.postDelayed({
|
||||
if (awaitingFreshFrame) {
|
||||
awaitingFreshFrame = false
|
||||
videoView?.alpha = 1f
|
||||
}
|
||||
}, FRESH_FRAME_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
lp.width = targetW
|
||||
lp.height = targetH
|
||||
view.layoutParams = lp
|
||||
@@ -1228,14 +1367,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1741,7 +1741,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 +1853,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 +1887,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 +1903,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(),
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.5.5",
|
||||
"version": "0.6.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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -154,7 +156,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 +445,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 +781,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 +1016,7 @@
|
||||
progressInterval = setInterval(() => {
|
||||
if (isPlaying && !isSeeking && onReportProgress) {
|
||||
onReportProgress(currentTime, false, reportMediaId);
|
||||
mirrorElementStateToRust(false);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
@@ -1348,6 +1450,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
|
||||
@@ -1676,18 +1805,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 +1986,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 +2257,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 +2339,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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -27,31 +27,52 @@ 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 is still
|
||||
* off**, because turning it on surfaced a different gap: the background-audio
|
||||
* handoff (UR-040) can only *return* through the HTML5 element.
|
||||
* `applyPendingForegroundSeek` bails on `!videoElement`, the HLS re-init effect
|
||||
* bails on `!useHtml5Element`, and `handleCanPlay` — the event that owns the
|
||||
* post-handoff position and play state — is an element event that never fires
|
||||
* natively. So coming back from background audio leaves playback dead.
|
||||
*
|
||||
* That is the same shape of mistake as DR-161: a verified sub-path shipped as a
|
||||
* default over an unverified one. The evidence standard this branch set for the
|
||||
* picture applies to the handoff too, so the flip waits for it (DR-190).
|
||||
*
|
||||
* An explicit stored choice still wins in both directions, so anyone who turned
|
||||
* it on keeps it on.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-172
|
||||
* TRACES: UR-003, UR-004 | DR-188
|
||||
*/
|
||||
function load(): boolean {
|
||||
if (typeof localStorage === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
// Private-mode / disabled storage — default to the safe (HTML5) path.
|
||||
// Private-mode / disabled storage — default to the path whose handoff works.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -80,9 +101,9 @@ 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 opt-in for the native Android video path. **Defaults to off** — see
|
||||
* `load()`. The name says "experimental" because the flag remains a suppressor
|
||||
* of Rust's backend choice, not a promoter of it.
|
||||
*/
|
||||
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -744,10 +744,11 @@
|
||||
</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.
|
||||
The picture works, but background audio does not come back from
|
||||
the lockscreen on this path yet — leave it off unless you are
|
||||
helping test it.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user