Compare commits
9
Commits
v0.11.0
...
7545de6cc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7545de6cc7 | ||
|
|
0445a6d0aa | ||
|
|
a8c44145ff | ||
|
|
fecd6022fe | ||
|
|
156b9e3684 | ||
|
|
4f6cf22419 | ||
|
|
84cf31b929 | ||
|
|
7cc392d78f | ||
|
|
109700b949 |
@@ -109,7 +109,7 @@ jobs:
|
||||
# at "warn" until its class is cleared and it can be promoted to "error".
|
||||
# Lower this as you clear them. Never raise it to make a build pass.
|
||||
- name: Lint
|
||||
run: bun run lint -- --max-warnings=159
|
||||
run: bun run lint -- --max-warnings=158
|
||||
|
||||
- name: Check TypeScript
|
||||
run: |
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
||||
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
||||
- [Linux Native Video Spike](specs/linux-native-video-spike.md)
|
||||
- [Backend-Owned Stream Selection](specs/backend-owned-stream-selection.md)
|
||||
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
||||
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
||||
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
||||
@@ -42,6 +41,7 @@
|
||||
- [Scoped Search Boundary](specs/scoped-search-boundary.md)
|
||||
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
|
||||
- [Frontend Domain Model](specs/frontend-domain-model.md)
|
||||
- [Desktop Native Video](specs/desktop-native-video.md)
|
||||
- [Build Provenance](specs/build-provenance.md)
|
||||
|
||||
# Build & Release
|
||||
|
||||
@@ -673,8 +673,161 @@ device profile. Sending it there — not just on the transcode URL — is what m
|
||||
the cap real: a stream the server decides to *direct play* is served at the
|
||||
source file's own bitrate, and no URL parameter afterwards can reduce it.
|
||||
|
||||
#### Two levels of ceiling
|
||||
|
||||
**Location**: `src-tauri/src/repository/online.rs` (TRACES: UR-074, UR-079 | DR-226)
|
||||
|
||||
There are two, and they are not the same thing:
|
||||
|
||||
| | Set by | Lives until | Read via |
|
||||
|---|---|---|---|
|
||||
| **Device default** | Settings (`player_set_video_settings`) | Persisted; restored at startup | `streaming_quality()` |
|
||||
| **Per-playback override** | The in-player picker (`player_set_stream_quality`) | The next item starts playing | `playback_quality_override()` |
|
||||
|
||||
`effective_streaming_quality()` resolves the pair — override first, else default —
|
||||
and **is the only thing stream construction may read**. Every URL builder and the
|
||||
`PlaybackInfo` negotiation go through it, for the reason the process-wide static
|
||||
existed in the first place: if the negotiation and the URL builder disagree, the
|
||||
cap leaks — the negotiation authorises a direct play the builder then never gets
|
||||
to constrain, or the reverse.
|
||||
|
||||
> The override exists because a single global cannot express "this 4K remux needs
|
||||
> a ceiling, that podcast does not". The picker had documented itself as a "this
|
||||
> film, this connection" control since it was written, but was implemented by
|
||||
> writing the *default* — so dropping one awkward film to 2 Mbps silently capped
|
||||
> every video played afterwards for the rest of the process, with Settings still
|
||||
> showing the old value. It is cleared on every `player_play_item` /
|
||||
> `player_play_queue` / `player_play_tracks`, which is what stops it surviving
|
||||
> into an autoplayed next episode where nobody would reopen the picker.
|
||||
|
||||
### Stream selection
|
||||
|
||||
**Location**: `src-tauri/src/repository/stream_selection.rs`,
|
||||
`OnlineRepository::get_stream_selection` (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)
|
||||
|
||||
**Rust decides *what stream*. The player decides *how to deliver it*.** That line
|
||||
is the whole design. A backend with genuine adaptive selection (ExoPlayer over a
|
||||
multi-variant playlist) is left to do it; Rust chooses what to request and never
|
||||
paces bytes.
|
||||
|
||||
`get_stream_selection` returns one self-describing `StreamSelection` in place of
|
||||
the bare URL `get_video_stream_url` used to hand out:
|
||||
|
||||
| Field | Carries |
|
||||
|---|---|
|
||||
| `url` | What to open |
|
||||
| `transport` | `Hls` / `Progressive` / `LocalFile` — how to fetch it |
|
||||
| `playback_kind` | `DirectPlay` / `DirectStream` / `Transcode` — what the server is doing to the source |
|
||||
| `rendition` | The negotiated ceiling and codecs; `None` for a direct play, which *is* the source |
|
||||
| `available` | The quality ladder as it applies to this media source (DR-227) |
|
||||
| `needs_transcoding` | Derived from `playback_kind`, so the rule is answered once |
|
||||
|
||||
Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a
|
||||
discriminant rather than comparing text.
|
||||
|
||||
> **Why `transport` exists.** `VideoPlayer.svelte` chose its loader with
|
||||
> `url.includes(".m3u8")`, in two places. Rust *built* that URL and knows exactly
|
||||
> what it is; re-deriving it downstream by substring match is a domain fact
|
||||
> reconstructed in the presentation layer — the same class of error as leaking
|
||||
> item-type taxonomy, and one that fails silently in **both** directions: a
|
||||
> progressive file served from a path containing the substring gets an HLS
|
||||
> loader, and a playlist served from a path without it does not.
|
||||
>
|
||||
> The paths that never negotiate get the same shape from Rust rather than letting
|
||||
> a caller assemble one — `media_local_selection` for a downloaded file,
|
||||
> `LiveStreamInfo.transport` for a live channel — so there is no second place
|
||||
> where a transport is decided.
|
||||
|
||||
#### The playback-kind decision
|
||||
|
||||
`decide_playback_kind` is a free function and pure, so every branch is testable
|
||||
from `PlaybackInfo` fixtures without a server. Order matters — the two
|
||||
client-side overrides come first, because each describes a case where the
|
||||
server's answer is right about the *file* and wrong about what this app will do
|
||||
with it:
|
||||
|
||||
1. **Undecodable audio → `Transcode`.** Jellyfin 10.11.5 honours a
|
||||
DirectPlayProfile's container and video codec but *ignores its audio codec*,
|
||||
so it offers direct play for an E-AC-3 track the webview renders in silence.
|
||||
A silent direct play is worse than a transcode.
|
||||
2. **A pinned audio track → `Transcode`.** Not a defect in the server's answer, a
|
||||
different question: the file has one default track and the viewer asked for
|
||||
another.
|
||||
3. Otherwise `supports_direct_play` → `DirectPlay`, else `supports_direct_stream`
|
||||
→ `DirectStream`, else `Transcode`.
|
||||
|
||||
A direct **stream** is a remux — codecs copied, container repackaged. It is cheap
|
||||
and is deliberately *not* counted as transcoding; conflating the two would report
|
||||
a free passthrough as a server-side re-encode.
|
||||
|
||||
> **What this is worth, measured.** Against the development server (Jellyfin
|
||||
> 10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation
|
||||
> per profile:
|
||||
>
|
||||
> | Profile | Direct play |
|
||||
> |---|---|
|
||||
> | Linux / WebKitGTK (`h264` only, 2ch) | 3/40 — **7%** |
|
||||
> | Android / ExoPlayer (`h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch) | 34/40 — **85%** |
|
||||
>
|
||||
> The library is ~80% hevc (`hevc+eac3` alone is a third of it), which is why the
|
||||
> two diverge so hard.
|
||||
>
|
||||
> **Read that 85% as a ceiling, not a result.** It was measured with a profile
|
||||
> containing `ac3,eac3`. The Android device this was later run on reports neither
|
||||
> in its `MediaCodecList` — no Dolby licence, which is normal for a tablet — so
|
||||
> eac3 content, about a third of the sampled library, correctly transcodes there.
|
||||
> What any given device achieves depends on its own codec list, and on the
|
||||
> profile being derived from the renderer at all (DR-234), which it was not when
|
||||
> the figure was taken.
|
||||
>
|
||||
> **The payoff is still overwhelmingly Android**, because that is where a real
|
||||
> decoder is already doing the work. Linux stays near 7% until libmpv decodes the
|
||||
> picture — the h264-only profile is a WebKitGTK constraint, not a JellyTau
|
||||
> choice, and is what `linux-native-video-spike.md` exists to remove. A reviewer
|
||||
> should not expect this code to fix Linux on its own.
|
||||
|
||||
#### The quality ladder per source
|
||||
|
||||
`quality_options_for_source(source_bitrate)` returns every rung, each marked with
|
||||
`exceeds_source`: true when that rung's ceiling is at or above what the source
|
||||
itself carries, so selecting it produces the same bytes as `Original`. The
|
||||
frontend draws the list and drops the redundant rungs; it does not decide which
|
||||
they are.
|
||||
|
||||
- `Original` is never marked — it *is* the source.
|
||||
- An unreported source bitrate (some containers have none; the sampled library
|
||||
has `avi` files with no bitrate at all) marks **nothing** redundant, keeping
|
||||
every rung offered. That is the safe direction: the viewer keeps every choice.
|
||||
|
||||
#### No adaptive ladder to preserve
|
||||
|
||||
**TRACES: UR-079 | DR-229 (Won't Do)**
|
||||
|
||||
Mid-playback re-negotiation on throughput was scoped and dropped on measurement.
|
||||
A master playlist from this server carries exactly **one** `EXT-X-STREAM-INF`:
|
||||
Jellyfin builds it from the single rendition the request asked for rather than
|
||||
publishing a ladder. So there is no adaptation for hls.js to be preserving and
|
||||
none that mpv would lose — the claim that there was is recorded in
|
||||
`playback-backend-unification.md` and does not hold. "Adapt mid-stream" collapses
|
||||
into "pick well at open", which is what the two levels of ceiling and the
|
||||
per-source ladder already are.
|
||||
|
||||
Kept here because it is a measurement, not an opinion: a server that *does*
|
||||
publish a ladder would change the answer, and the re-negotiation path below is
|
||||
the hook that work would build on.
|
||||
|
||||
#### Re-negotiation
|
||||
|
||||
One mechanism, not two. `player_seek_video`, `player_switch_audio_track` and
|
||||
`player_set_stream_quality` all return a tagged `strategy` saying who reloads —
|
||||
the backend handles a native backend itself and hands the webview a
|
||||
`StreamSelection` for `reloadSource`. Note the wire wart: tauri-specta keeps
|
||||
these response fields snake_case (`seek_offset`), while the `strategy` tag itself
|
||||
is camelCase.
|
||||
|
||||
The frontend names a variant and nothing else; the labels the picker shows are
|
||||
served over IPC by `player_get_streaming_qualities`.
|
||||
served over IPC — from `available` on the selection, or
|
||||
`player_get_streaming_qualities` for the Settings list.
|
||||
|
||||
## Background workers
|
||||
|
||||
|
||||
@@ -802,6 +802,45 @@ by exactly the inset.
|
||||
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
||||
can safely be re-sent on resume.
|
||||
|
||||
## Stream Transport
|
||||
|
||||
**Location**: `src/lib/player/streamTransport.ts`
|
||||
**TRACES**: UR-079 | DR-225 | UT-214
|
||||
|
||||
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
|
||||
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
|
||||
`selection.transport`. `elementSrcFor` is its template companion: the element's
|
||||
`src` is emptied only when hls.js is driving it.
|
||||
|
||||
The split is the point. **The transport is the stream's property and comes from
|
||||
Rust; whether a given loader exists is the browser's, and is the only thing
|
||||
decided here.**
|
||||
|
||||
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
|
||||
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
|
||||
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
|
||||
> here by substring match was a domain fact reconstructed in the presentation
|
||||
> layer, and it fails silently in both directions. The two tests that pin it are
|
||||
> the ones that failed against the old implementation: a `progressive` stream
|
||||
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
|
||||
> whose URL contains no `.m3u8` must.
|
||||
>
|
||||
> Logic lives in a plain `.ts` module rather than in the component for the usual
|
||||
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
|
||||
|
||||
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
|
||||
derived from it. A reload replaces the selection **wholesale** (the adapter's
|
||||
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
|
||||
drift apart. The background-audio handoff states the transport it is moving to —
|
||||
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
|
||||
inferred.
|
||||
|
||||
The quality picker is filled from `selection.available` (DR-227): rungs the
|
||||
backend marked `exceedsSource` are not drawn, because they produce the same bytes
|
||||
as `Original`. Nothing is optimistically assigned when the viewer picks a rung —
|
||||
what the menu shows comes from the selection the backend hands back, since a
|
||||
ceiling above the source bitrate *is* the source.
|
||||
|
||||
## Native Video Store
|
||||
|
||||
**Location**: `src/lib/stores/nativeVideo.ts`
|
||||
|
||||
@@ -132,6 +132,55 @@ sequenceDiagram
|
||||
Note over Store: UI updates reactively
|
||||
```
|
||||
|
||||
## Video Stream Selection Flow
|
||||
|
||||
**TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228**
|
||||
|
||||
Before a video plays, Rust decides *what stream* — direct play, remux or
|
||||
transcode, over which transport — and hands the player one self-describing
|
||||
`StreamSelection`. The page no longer inspects the URL to work any of this out.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Page as player/[id]/+page.svelte
|
||||
participant Repo as HybridRepository
|
||||
participant Online as OnlineRepository
|
||||
participant Server as Jellyfin
|
||||
participant VP as VideoPlayer.svelte
|
||||
|
||||
Page->>Repo: playerLocalMediaPath(id)
|
||||
alt a completed download exists
|
||||
Page->>Repo: mediaLocalSelection(path)
|
||||
Note over Page: LocalFile / DirectPlay, no ladder —<br/>nothing about a file on disk re-negotiates
|
||||
else stream from the server
|
||||
Page->>Repo: getStreamSelection(id, mediaSourceId)
|
||||
Repo->>Online: get_stream_selection()
|
||||
Online->>Online: effective_streaming_quality()
|
||||
Note over Online: per-playback override, else device default
|
||||
Online->>Server: POST /Items/{id}/PlaybackInfo<br/>(device profile + ceiling)
|
||||
Server-->>Online: MediaSource {supportsDirectPlay,<br/>supportsDirectStream, transcodingUrl, bitrate}
|
||||
Online->>Online: decide_playback_kind()
|
||||
alt Transcode
|
||||
Online->>Online: adopt/stop prior play session,<br/>build HLS URL
|
||||
Note over Online: Transport::Hls
|
||||
else DirectPlay / DirectStream
|
||||
Online->>Online: /Videos/{id}/stream?static=true
|
||||
Note over Online: Transport::Progressive,<br/>rendition = None (it IS the source)
|
||||
end
|
||||
Online->>Online: quality_options_for_source(bitrate)
|
||||
Online-->>Page: StreamSelection
|
||||
end
|
||||
Page->>VP: selection
|
||||
VP->>VP: videoLoaderFor(selection, caps)
|
||||
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
|
||||
```
|
||||
|
||||
The selection travels with the stream from then on. A reload — a quality change,
|
||||
an audio-track switch, a transcoded seek — returns a *new* selection through the
|
||||
same tagged `strategy` response, so transport and URL can never disagree; and the
|
||||
queue item carries the transport so `player_seek_video` picks its seek strategy
|
||||
from the backend's decision rather than from the URL string.
|
||||
|
||||
## Playback Mode Transfer Flow
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -88,6 +88,8 @@ For a narrative overview of the system design, see
|
||||
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done |
|
||||
| UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done |
|
||||
| UR-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | Medium | Done |
|
||||
| UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done |
|
||||
| UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding | Medium | Proposed |
|
||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||
|
||||
---
|
||||
@@ -132,6 +134,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented |
|
||||
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
|
||||
| IR-033 | libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as *data* symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `get_proc_address` must return the pointer stored **at** that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs` | Playback | UR-080 | Proposed |
|
||||
|
||||
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
|
||||
> integration requirements were written when libmpv was expected to be the single
|
||||
@@ -416,6 +419,19 @@ Internal architecture, components, and application logic.
|
||||
| DR-222 | Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile | Tooling | - | Done |
|
||||
| DR-223 | The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app | Security | UR-012 | Done |
|
||||
| DR-224 | Backgrounding the app obeys the background-audio toggle on every renderer. The toggle (UR-040) was built for the WebView `<video>` path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a `MediaSessionService` — a foreground media service whose purpose is to keep playing while the app is hidden. Nothing paused it and nothing in the codebase paused on background, so locking the screen kept the audio going whether or not the toggle was on: the toggle governed a handoff that no longer had a gap to bridge, and users got background playback they never asked for. The decision now lives in Rust (`player/background_policy.rs`) and both renderers obey it: a video with the toggle off pauses, with the toggle on hands off to audio, music is never paused by backgrounding, and picture-in-picture keeps playing because the window is still on screen (UR-041). It takes no renderer parameter on purpose — the split between the two paths is what produced the defect | Player | UR-040 | Done |
|
||||
| DR-225 | `StreamSelection` replaces the bare URL returned for playback: URL, `Transport` (hls / progressive / localFile), `PlaybackKind` (directPlay / directStream / transcode), the negotiated `Rendition`, the ladder this source can offer, and a `needs_transcoding` flag derived in Rust so "which kinds count as transcoding" is answered once. Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a discriminant rather than comparing text. The field that mattered most is `transport`: `VideoPlayer.svelte` chose its loader with `url.includes(".m3u8")` in two places, a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions (a progressive file served from a path containing the substring gets an HLS loader; a playlist served from one without it does not). The paths that never negotiate — a downloaded file, a live channel — get the same shape from Rust (`media_local_selection`, `LiveStreamInfo.transport`) rather than having the page assemble one, so there is no second place where a transport is decided | Playback | UR-079 | Done |
|
||||
| DR-226 | The bandwidth ceiling is two-level: a durable device default (Settings, persisted, restored at startup) and a per-playback override the in-player picker sets. The picker's own documentation had called it a "this film, this connection" control since it was written, but it was implemented by writing the process-wide default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, while the Settings screen still displayed the old value and nothing in the UI admitted the change. The override is cleared whenever playback moves to a new item, which is what keeps it from surviving into an autoplayed next episode where nobody would reopen the picker. `effective_streaming_quality()` is the single resolution point; every URL builder and the `PlaybackInfo` negotiation go through it, because a negotiation that authorises a direct play the URL builder then constrains (or the reverse) leaks the cap | Playback | UR-074, UR-079 | Done |
|
||||
| DR-227 | The quality picker is filled from what *this* media source can offer, not from the fixed eight-rung enum. Rust marks each rung `exceeds_source` when its ceiling is at or above the source's own bitrate — such a rung produces the same bytes as `Original`, so offering it is another way to spell one choice — and the frontend simply does not draw those. `Original` is never marked (it *is* the source) and a source whose bitrate the server does not report (the sampled library has `avi` files with none) marks nothing redundant, keeping every rung offered, which is the safe direction. The picker also shows what the server is actually doing with the stream, which only became knowable once `PlaybackKind` existed. Labels and detail lines come from Rust beside the numbers they describe, so a relabelled rung cannot drift out of step with what it does | UI | UR-070, UR-079 | Done |
|
||||
| DR-228 | Direct play and direct stream are negotiated rather than assumed away. `get_video_stream_url` always built an HLS transcode URL, so every video play burned server CPU even when the file would have played untouched. The decision now comes from `PlaybackInfo` under the device profile and the ceiling in force, with two client-side overrides applied on top because the server's answer is right about the *file* and wrong about what this app will do with it: undecodable audio (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec, so it offers direct play for an E-AC-3 track the webview renders in silence) and a viewer-pinned audio track the source file does not default to. Measured against the development server over a 400-item sample: **85% direct play on the Android profile, 7% on the Linux one** — the library is ~80% hevc and WebKitGTK can only claim h264, so the Linux figure is a property of the renderer, not of this code, and is what `linux-native-video-spike.md` exists to change. A direct *stream* is a remux and is deliberately not counted as transcoding | Playback | UR-079 | Done |
|
||||
| DR-229 | Mid-playback re-negotiation on throughput was scoped and **dropped on measurement**. The premise — that hls.js gives this app real adaptive bitrate and mpv would lose it — does not hold: a master playlist from the development server carries exactly one `EXT-X-STREAM-INF`, because Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. There is no adaptation to preserve, so "adapt mid-stream" collapses into "pick well at open", which is what DR-225 and DR-226 already are. Recorded rather than deleted because the conclusion is a measurement, not an opinion, and a server that does publish a ladder would change it — the DR-224 re-negotiation path is the hook that work would build on | Playback | UR-079 | Won't Do |
|
||||
| DR-230 | Every player backend consumes the same selection, proving the contract is player-agnostic rather than HTML5-shaped. The queue item carries the negotiated `transport`, so `player_seek_video` picks its seek strategy from the backend's own decision instead of the last `stream_url.contains(".m3u8")` in the codebase; items queued by a path that never negotiated (audio tracks, direct URLs) carry `None` and fall back to `needs_transcoding`, which is exact rather than a guess because every transcode this app requests is HLS (DR-140). The webview adapter's bridge carries the whole selection rather than a URL, so the component's HLS effect reads a tag instead of searching a string, and the background-audio handoff states the transport it is moving to (progressive mp3 out, HLS back) rather than leaving it to be inferred | Playback | UR-003, UR-004, UR-079 | Done |
|
||||
| DR-231 | An mpv video backend that composites beneath the transparent webview, the desktop counterpart of the Android TextureView arrangement. mpv renders through its **render API** into an FBO the toolkit binds (`vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO`), rather than by embedding a foreign window — which is what the 2024 "not possible on Wayland at all" conclusion was about and why it does not apply. On Linux that is a `GtkOverlay` with a `GtkGLArea` as main child and Tauri's own webview reparented as the overlay child; the mpv half is shared and only the surface differs per platform. Webview transparency alone suffices — no window-level transparency is used or needed | Playback | UR-080 | Proposed |
|
||||
| DR-232 | The mpv render context's lifetime is bound to the GL context it draws into: created on `realize`, freed on `unrealize`, on the same thread, with the update callback unregistered *before* the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called `mpv_render_context_free` and never tore down on `unrealize`, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the cause | Playback | UR-080 | Proposed |
|
||||
| DR-233 | Frame pacing goes through mpv's update callback, with `mpv_render_context_report_swap` after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and **judders at fullscreen** — reading as a compositing or GPU limit and being neither | Playback | UR-080 | Proposed |
|
||||
| DR-234 | The device profile is derived from the **renderer that will decode the stream**, not from a compile-time platform constant. `video_codecs` was `#[cfg(target_os)]`, which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower *audio* set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogue | Repository | UR-080, UR-070 | In Progress |
|
||||
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Proposed |
|
||||
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
|
||||
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | Proposed |
|
||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||
|
||||
---
|
||||
@@ -503,6 +519,8 @@ Internal architecture, components, and application logic.
|
||||
| UR-076 | - | DR-209 |
|
||||
| UR-077 | - | DR-217 |
|
||||
| UR-078 | - | DR-218 |
|
||||
| UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230 |
|
||||
| UR-080 | IR-033 | DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237 |
|
||||
|
||||
---
|
||||
|
||||
@@ -717,6 +735,10 @@ Internal architecture, components, and application logic.
|
||||
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
|
||||
| UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done |
|
||||
| UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done |
|
||||
| UT-212 | The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail | DR-224, DR-226 | Done |
|
||||
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
|
||||
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
|
||||
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ know how something *works*, read
|
||||
|
||||
**Next free requirement ids** (always re-check
|
||||
[requirements.md](../requirements.md) before allocating): **UR-079**,
|
||||
**IR-033**, **DR-225**. Three specs below suggested ids that have since been
|
||||
**IR-033**, **DR-232**. Three specs below suggested ids that have since been
|
||||
taken by other work; each carries a ⚠️ note at the top.
|
||||
|
||||
## Partially implemented
|
||||
@@ -37,18 +37,18 @@ taken by other work; each carries a ⚠️ note at the top.
|
||||
|---|---|---|
|
||||
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
|
||||
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv` → `libmpv2` crate swap |
|
||||
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-121/122/124/125 — the player quality selector and the read-through capture |
|
||||
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec |
|
||||
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
|
||||
|
||||
## Not started
|
||||
|
||||
| Spec | Blocked on / note |
|
||||
|---|---|
|
||||
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Phase 1 (delete the `.m3u8` sniff) stands alone. Unblocks Linux native video. |
|
||||
| [desktop-native-video.md](desktop-native-video.md) | mpv draws video on every desktop platform, then the webview `<video>` path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection. |
|
||||
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
|
||||
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
|
||||
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
|
||||
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. Needs an implementation spec that answers adaptive bitrate. |
|
||||
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. The adaptive-bitrate question it was waiting on is **answered**: the server publishes one `EXT-X-STREAM-INF`, so there is no ladder for mpv to lose (DR-229). `StreamSelection` (DR-225) is the contract to consume. |
|
||||
|
||||
## Design authority
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
# Spec: Backend-owned stream selection
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-079 (new) → DR-219 … DR-224 (new); **implements and extends
|
||||
DR-121**, currently allocated to
|
||||
[read-through-media-cache.md](read-through-media-cache.md) and not started.
|
||||
Re-check `requirements.md` before allocating — the ids moved twice while this was
|
||||
being written (`DR` max was 215, then 218).
|
||||
**UX spec:** the quality selector in `VideoPlayer.svelte` already exists; this
|
||||
changes what fills it, not how it looks.
|
||||
**Supersedes / revises:** takes DR-121 out of
|
||||
[read-through-media-cache.md](read-through-media-cache.md), which should keep
|
||||
only its capture/eviction half. Unblocks
|
||||
[linux-native-video-spike.md](linux-native-video-spike.md).
|
||||
|
||||
**Destination on completion:**
|
||||
[01-rust-backend.md](../architecture/01-rust-backend.md) — extends the
|
||||
"Streaming quality ladder" section; and
|
||||
[03-data-flow.md](../architecture/03-data-flow.md) — playback initiation. The
|
||||
durable half is the layer line and the `StreamSelection` contract; phases and
|
||||
acceptance criteria are disposable.
|
||||
|
||||
## Summary
|
||||
|
||||
Make Rust the single owner of *which stream to play* — direct play or transcode,
|
||||
at what ceiling, over what transport — and hand every player backend a
|
||||
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
|
||||
`<video>`/hls.js path all become consumers of the same decision rather than three
|
||||
places that re-derive it.
|
||||
|
||||
Nothing about how playback *looks* changes. What changes is that the frontend
|
||||
stops inferring transport from a URL string, and that direct play becomes
|
||||
possible at all.
|
||||
|
||||
## Motivation
|
||||
|
||||
Four concrete problems, all the same shape.
|
||||
|
||||
**1. The frontend sniffs transport out of the URL.**
|
||||
[VideoPlayer.svelte:569](../../src/lib/components/player/VideoPlayer.svelte#L569):
|
||||
|
||||
```ts
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
```
|
||||
|
||||
and again inline at line 2364. Rust *built* that URL and knows exactly what it
|
||||
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
|
||||
path, serve a progressive file, and this silently picks wrong. This is the
|
||||
boundary rule in miniature — not item-type taxonomy, but the same error: a
|
||||
domain fact reconstructed in the presentation layer because the wire shape did
|
||||
not carry it.
|
||||
|
||||
**2. There is no direct-play path.** `get_video_stream_url` always builds an HLS
|
||||
transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video
|
||||
play burns server CPU, even when the file would play untouched. This is the cost
|
||||
the Linux native-video work exists to remove, and it cannot be removed without a
|
||||
decision that does not currently exist anywhere in the codebase.
|
||||
|
||||
**3. Quality is a process-wide global.** `streaming_quality()` /
|
||||
`set_streaming_quality()` in `repository/online.rs` read and write a static.
|
||||
It is not per-session or per-item, so it cannot express "this 4K remux needs a
|
||||
ceiling, that podcast does not", and two concurrent playbacks would share one
|
||||
setting.
|
||||
|
||||
**4. Rust cannot say what qualities *this* media source supports.** The selector
|
||||
is populated from a fixed enum rather than from what the source actually offers.
|
||||
DR-121 already names this; it has not been built.
|
||||
|
||||
### The prior question
|
||||
|
||||
Finding 3 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
|
||||
in this repo suggests **there is no ABR today**: a single rendition is requested,
|
||||
no level-handling code exists anywhere in the frontend, and a quality switch is
|
||||
implemented by re-opening the stream.
|
||||
|
||||
**Run this before sizing the adaptation work.** It needs a live server:
|
||||
|
||||
```
|
||||
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
|
||||
| grep -c EXT-X-STREAM-INF
|
||||
```
|
||||
|
||||
`1` → there is no adaptation to preserve, and the adaptation half of this spec
|
||||
collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies.
|
||||
**Everything else in this spec is worth doing either way** — the ownership
|
||||
problems above are independent of the answer.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|---|---|---|
|
||||
| Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test. |
|
||||
| Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that *knows* rather than infers. Today the frontend guesses from `.m3u8`. |
|
||||
| Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121. |
|
||||
| The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static. |
|
||||
| Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent. |
|
||||
| Frame-level delivery *within* the selected stream, including a player's own ABR | **Player** | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses *what to request*, never how a player paces bytes. See "The line". |
|
||||
| Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list. |
|
||||
| Poster, letterbox, controls, overlay z-order | Frontend | Unchanged. |
|
||||
|
||||
### The line
|
||||
|
||||
**Rust decides *what stream*. The player decides *how to deliver it*.**
|
||||
|
||||
This matters most for ExoPlayer, which already does real adaptive track selection
|
||||
over HLS. This spec must not reimplement that or fight it — if a multi-variant
|
||||
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
|
||||
same restraint applies to any future backend that gains the capability. Rust only
|
||||
steps in where the player has no such ability (mpv) *and* the server actually
|
||||
offers a ladder.
|
||||
|
||||
Borderline row, with its tie-breaker: "which media source of a multi-source item"
|
||||
looks like a user choice, and its *presentation* is. The default and the
|
||||
constraint set are domain → **Rust**, per the borderline-defaults-to-Rust rule.
|
||||
|
||||
## Design
|
||||
|
||||
### The contract
|
||||
|
||||
One self-describing selection replaces the bare URL. Nested fields are
|
||||
camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are
|
||||
tagged so the frontend matches a tag instead of parsing a string.
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamSelection {
|
||||
pub url: String,
|
||||
pub transport: Transport,
|
||||
pub playback_kind: PlaybackKind,
|
||||
/// The negotiated rendition; None when direct-playing the source as-is.
|
||||
pub rendition: Option<Rendition>,
|
||||
/// What this media source can offer — fills the selector (DR-121).
|
||||
pub available: Vec<QualityOption>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Transport { Hls, Progressive, LocalFile }
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
|
||||
```
|
||||
|
||||
`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks
|
||||
hls.js on `Hls` and the element's own loader otherwise — a tag match, not a
|
||||
substring search.
|
||||
|
||||
### Re-negotiation
|
||||
|
||||
Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new
|
||||
`StreamSelection` plus the position to resume at. The existing
|
||||
`playerSetStreamQuality` response already has exactly the right shape — a tagged
|
||||
`strategy` that tells the caller who reloads, with the backend handling native
|
||||
itself and handing HTML5 a URL for `reloadSource`
|
||||
([index.ts:198](../../src/lib/player/index.ts#L198)). **Extend that; do not
|
||||
invent a second mechanism.** It is the one piece of this that is already right.
|
||||
|
||||
Note the existing wart to preserve or fix deliberately, not accidentally:
|
||||
tauri-specta keeps those response fields snake_case (`new_url`), and the facade
|
||||
comments say so.
|
||||
|
||||
### Phases
|
||||
|
||||
1. **DR-219** `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No
|
||||
behaviour change — pure ownership move, and independently shippable.
|
||||
2. **DR-220** Per-session quality ceiling replacing the `online.rs` static.
|
||||
3. **DR-221** `available` populated from the media source (DR-121's substance).
|
||||
4. **DR-222** Direct-play/direct-stream negotiation via `PlaybackInfo`. This is
|
||||
the phase that unlocks native video and removes the transcode.
|
||||
5. **DR-223** Adaptation, **only if the playlist check says a ladder exists**.
|
||||
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
|
||||
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
|
||||
playlist is a last resort, not a starting point.
|
||||
6. **DR-224** ExoPlayer and mpv consume `StreamSelection` unchanged, proving the
|
||||
contract is player-agnostic rather than HTML5-shaped.
|
||||
|
||||
Phases 1–4 stand on their own merits with no dependency on the ladder question.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rendering, compositing, and the Linux native-video work itself. This spec
|
||||
unblocks [linux-native-video-spike.md](linux-native-video-spike.md); it does
|
||||
not contain it.
|
||||
- Replacing hls.js. It stays as the HLS loader for the webview path.
|
||||
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
|
||||
- The download/capture half of [read-through-media-cache.md](read-through-media-cache.md)
|
||||
(DR-122, DR-124, DR-125), which keeps its own spec.
|
||||
- Audio. The same argument applies, but video is where the transcode cost is.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites)
|
||||
and transport comes from the tagged enum.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes — and the reviewer confirms by reading that
|
||||
no transport/kind decision was reconstructed in `src/`, since the tripwire
|
||||
only catches item-type array literals.
|
||||
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
|
||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes and
|
||||
coverage stays ≥ the CI ratchet.
|
||||
- [ ] The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is
|
||||
started or dropped.
|
||||
- [ ] DR-121 is removed from `read-through-media-cache.md` with a pointer here.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch
|
||||
(supported container direct-plays; unsupported codec transcodes; a ceiling
|
||||
below the source bitrate transcodes even when the codec is fine).
|
||||
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
|
||||
- Frontend: adapter selection driven by `transport`, including the case a URL
|
||||
ending `.m3u8` is served as `Progressive` — that test fails on today's code,
|
||||
which is the point.
|
||||
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
|
||||
- No test asserts a URL substring.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| `StreamSelection` / `Transport` | `UR-079 \| DR-219` |
|
||||
| Per-session ceiling | `UR-074 \| DR-220` |
|
||||
| `available` from media source | `UR-079 \| DR-221, DR-121` |
|
||||
| Direct-play negotiation | `UR-079 \| DR-222` |
|
||||
| Adaptation, if built | `UR-079 \| DR-223` |
|
||||
| ExoPlayer/mpv consumers | `UR-003, UR-004 \| DR-224` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Phase 1 is worth doing on its own**, even if everything after it is dropped.
|
||||
It removes a real leak and costs almost nothing.
|
||||
- Do not frame any phase as "no Rust changes required" — that framing is what
|
||||
produced the leak `scoped-search-boundary.md` records.
|
||||
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from
|
||||
real traffic, never from a side-channel poller.
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes. Requirement ids in particular moved twice
|
||||
during the writing of this spec.
|
||||
@@ -0,0 +1,423 @@
|
||||
# Spec: Desktop native video — mpv renders the picture, everywhere
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new)
|
||||
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
|
||||
what is behind the controls.
|
||||
**Supersedes / revises:** consumes and closes
|
||||
[linux-native-video-spike.md](linux-native-video-spike.md), whose gates
|
||||
authorised exactly this spec and nothing more. Settles finding 2 of
|
||||
[playback-backend-unification.md](playback-backend-unification.md) on the
|
||||
desktop; finding 3 was already settled by DR-229. Absorbs the video half of what
|
||||
[windows-native-audio-backend.md](windows-native-audio-backend.md) leaves open.
|
||||
**Depends on:** backend-owned stream selection (DR-225 … DR-230), the branch
|
||||
below this one. mpv is a *consumer* of `StreamSelection`, never a second place to
|
||||
decide what to play.
|
||||
|
||||
**Destination on completion:**
|
||||
[05-platform-backends.md](../architecture/05-platform-backends.md) — a "Native
|
||||
Video Compositing (Desktop)" section beside the existing Android one, which this
|
||||
mirrors; and [01-rust-backend.md](../architecture/01-rust-backend.md) — the
|
||||
device profile becomes renderer-dependent, beside the stream-selection section.
|
||||
**The spike is deleted in the same commit**, its three traps and its
|
||||
hardware-decode table folded in; they are the durable half.
|
||||
|
||||
## Summary
|
||||
|
||||
mpv decodes and draws video on **every desktop platform**, composited beneath the
|
||||
transparent webview, exactly as Android already does with ExoPlayer. The HTML5
|
||||
`<video>` path and hls.js are then **deleted**, not merely bypassed.
|
||||
|
||||
The user-visible change is that most video stops being re-encoded by the server
|
||||
before it can be watched. The change for whoever maintains this is that video
|
||||
goes from three renderers to two.
|
||||
|
||||
## Motivation
|
||||
|
||||
### The transcode is a decoder constraint, not a rendering one
|
||||
|
||||
Desktop video goes through an h264 HLS transcode because the picture is drawn by
|
||||
a WebKitGTK `<video>` element, and that element decodes little else. The device
|
||||
profile therefore claims `h264` alone. That is not a statement about the machine
|
||||
— the same machine runs mpv, which decodes essentially everything in the library
|
||||
— it is a statement about which widget is holding the frame.
|
||||
|
||||
DR-228 made the cost measurable. Over 40 items negotiated against the development
|
||||
server:
|
||||
|
||||
| Profile | Direct play |
|
||||
|---|---|
|
||||
| Desktop / WebKitGTK — `h264` only, 2ch | **7%** |
|
||||
| Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | **85%** |
|
||||
|
||||
The sampled library is ~80% hevc. **Those rows differ only by which component
|
||||
decodes.**
|
||||
|
||||
Moving the picture to mpv is what lets the desktop row claim what the machine
|
||||
can actually do, and that — not the compositing — is the product.
|
||||
|
||||
> **The 85% is a ceiling, not a shipped result.** It was measured with a profile
|
||||
> containing `ac3,eac3`. The Android device later used for verification reports
|
||||
> neither in its `MediaCodecList` — no Dolby licence, normal for a tablet — so
|
||||
> eac3 content, about a third of the sampled library, correctly transcodes there.
|
||||
> Realising any of this depends on DR-234, deriving the profile from the renderer
|
||||
> rather than from the platform, which is why that requirement is load-bearing
|
||||
> and not tidy-up.
|
||||
|
||||
### One desktop video path, not two
|
||||
|
||||
This is why the spec covers Windows rather than stopping at Linux.
|
||||
|
||||
Today video has **three** renderers: ExoPlayer, the WebKitGTK `<video>` element,
|
||||
and (on Android, via the opt-out) that same element again. A Linux-only version
|
||||
of this work would make it four, permanently: mpv on Linux, HTML5 on Windows,
|
||||
ExoPlayer on Android, plus hls.js underneath the HTML5 one. Every seek strategy,
|
||||
every track switch, every quality change, every lifecycle bug would then have one
|
||||
more place to be got right — and the HTML5 path would survive indefinitely
|
||||
because *something* would still need it.
|
||||
|
||||
Finishing the job removes that: **mpv on desktop, ExoPlayer on Android**, and
|
||||
`hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the webview video element all
|
||||
go. The maintenance win is the reason Windows is in this spec and not in a
|
||||
follow-up that never gets written.
|
||||
|
||||
### Three blockers are gone
|
||||
|
||||
1. **Compositing works, including Wayland.** The spike ran all six gates; the
|
||||
2024 "not possible on Wayland at all" claim is out of date when the render API
|
||||
is used instead of foreign-window embedding.
|
||||
2. **There is no ABR to lose.** DR-229: the server's master playlist carries one
|
||||
`EXT-X-STREAM-INF`. hls.js was demuxing, not adapting.
|
||||
3. **A direct-play path exists.** It did not when the spike was written. DR-228
|
||||
built it; DR-230 proved the contract is player-agnostic.
|
||||
|
||||
And on Windows specifically, `tauri-plugin-libmpv` lists Windows as its **fully
|
||||
tested** platform — the inverse of the Linux situation the spike had to
|
||||
disprove. The embedding difficulty was always WebKitGTK-specific.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|---|---|---|
|
||||
| **Which codecs this device can decode** | **Rust** | Domain: it is the input to Jellyfin's `PlaybackInfo` negotiation. It stops being a property of the *platform* and becomes a property of *the renderer in use* — see "The structural change". |
|
||||
| Which backend renders video | **Rust** | Rust already owns this (`use_html5_element` / `VideoBackend`). It stops being a `cfg!` constant and becomes a runtime fact. |
|
||||
| What stream to play (direct / remux / transcode, transport, ceiling) | **Rust — already decided** | DR-225. mpv consumes `StreamSelection`. Re-deriving any of it in a new backend would be the defect DR-225 exists to remove, restated. |
|
||||
| Creating the GL surface, reparenting the webview, owning the render context | **Rust (platform layer)** | Native window and GL-context lifetime. Not presentation, and not expressible above the IPC boundary at all. |
|
||||
| Render-context ↔ GL-context lifetime binding | **Rust** | A correctness invariant over native resources. DR-232. |
|
||||
| Frame pacing (update callback, `report_swap`) | **Rust** | Timing against the compositor; mpv's own contract. |
|
||||
| Hardware-decode selection | **Rust** | A capability question about the machine, answered from what mpv reports it actually selected. |
|
||||
| Z-order of controls over video, overlay chrome, letterbox colour | **Frontend / mpv** | Presentation. Controls already draw over a transparent webview on Android; mpv paints its own letterbox bars (better than the Android equivalent, which shipped DR-194 as a defect). |
|
||||
| Whether the surface is visible right now | **Frontend** | `nativeVideoActive` already exists and toggles `data-native-video`. Unchanged. |
|
||||
|
||||
### The structural change
|
||||
|
||||
Everything above is routine except one row, and it carries the whole benefit.
|
||||
|
||||
`video_codecs` in `build_device_profile` is a **compile-time constant per
|
||||
platform**:
|
||||
|
||||
```rust
|
||||
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
||||
let (video_codecs, audio_codecs) = ("h264".to_string(), "aac,mp3,opus,…");
|
||||
```
|
||||
|
||||
That is correct only while a build has exactly one video renderer. It must be
|
||||
derived from **which renderer will decode this stream**, which is runtime state.
|
||||
|
||||
It looks like configuration and is not: it is the input that decides whether the
|
||||
server re-encodes, it changes when Jellyfin's API or our renderer changes, and
|
||||
getting it wrong fails *silently* — a claimed codec the renderer cannot decode is
|
||||
a black picture or silence, which is DR-148 and DR-228's audio override already.
|
||||
|
||||
**Write this against "the active video renderer", never `cfg!(target_os)`.** It
|
||||
is the single piece that must not be Linux-shaped, because phase 2 reuses it
|
||||
unchanged.
|
||||
|
||||
## Design
|
||||
|
||||
### Backend and compositing (DR-231, IR-033)
|
||||
|
||||
An `MpvVideoBackend` beside the existing `MpvBackend` (audio). The mpv side —
|
||||
render context, FBO, update callback, hwdec — is **shared**; only the surface
|
||||
differs per platform:
|
||||
|
||||
| Platform | Surface | Status |
|
||||
|---|---|---|
|
||||
| Linux (X11 + Wayland) | `gdk_cairo_draw_from_gl()` in the default vbox's `draw` handler, over a `GdkGLContext` on its `GdkWindow`. No reparenting — see below | Render path proven by the spike; the *overlay* approach it used is rejected |
|
||||
| Windows | Native HWND child beneath a transparent WebView2 | Phase 2 |
|
||||
|
||||
`vo=libmpv` plus `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO`.
|
||||
Webview transparency via `with_transparent(true)` — no window-level transparency;
|
||||
the spike showed it is neither used nor needed.
|
||||
|
||||
**G1's untested half failed, and the design changed because of it.**
|
||||
|
||||
Reparenting Tauri's webview into a `GtkOverlay` attaches cleanly and then aborts
|
||||
the process on the first click. `tauri-runtime-wry` connects a
|
||||
button-press handler to the webview that walks a hard-coded path:
|
||||
|
||||
```rust
|
||||
webview.parent() // "This one should be GtkBox"
|
||||
.parent() // ...and this one the GtkWindow
|
||||
.downcast::<gtk::Window>().unwrap()
|
||||
```
|
||||
|
||||
An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast fails,
|
||||
and the panic is non-unwinding so it kills the app. Nothing in configuration
|
||||
avoids it: on Linux `attach_resize_handler` is called **unconditionally** (the
|
||||
Windows equivalent is guarded by `is_decorated()`), and the decoration check that
|
||||
would make the handler inert runs *after* the unwrap.
|
||||
|
||||
**So the webview is not moved at all.** mpv draws into the *default vbox's own
|
||||
`draw` handler* instead, via `gdk_cairo_draw_from_gl()` over a `GdkGLContext`
|
||||
created on that widget's `GdkWindow`. GTK3 draws a container before its children,
|
||||
so the webview composites on top for free — the same z-order the overlay was for,
|
||||
without touching the widget tree Tauri walks.
|
||||
|
||||
That is strictly better than the overlay it replaces: no reparent, no extra
|
||||
widget, and the arrangement cannot be broken by a Tauri upgrade that assumes its
|
||||
own layout. It is also why "the surface attached successfully" is not the gate —
|
||||
a click is.
|
||||
|
||||
Three traps from the spike, each of which cost a debugging cycle and each of
|
||||
which looks like a platform limitation and is not:
|
||||
|
||||
1. **`LC_NUMERIC` must be reset *after* `gtk::init()`.** mpv refuses to start
|
||||
under a non-C numeric locale. `mpv_backend.rs` already handles this but has no
|
||||
GTK init in front of it; here `gtk::init()` applies the user's locale
|
||||
afterwards and `mpv_create` returns null.
|
||||
2. **libepoxy exports GL entry points as *data* symbols.** There is no `glFoo`
|
||||
function — there is `epoxy_glFoo`, a variable holding a lazily-resolving
|
||||
pointer. `get_proc_address` must return the pointer **stored at** that symbol;
|
||||
returning the symbol's own address makes mpv jump into non-executable data and
|
||||
take SIGSEGV on the first GL call. The `epoxy` crate does this correctly but is
|
||||
unusable — its `gl_generator` dependency pulls a yanked `xml-rs`.
|
||||
3. **Frame pacing is not optional and its symptom misleads.** See DR-233.
|
||||
|
||||
### Render-context lifetime (DR-232) — the crash defence
|
||||
|
||||
The spike's one unexplained SIGSEGV landed in a *decoder* thread with no Tauri,
|
||||
GTK or GL frame in the stack, and three plausible causes failed to reproduce it
|
||||
across ~13 minutes of targeted stress.
|
||||
|
||||
What is **not** unexplained is that the spike had no defence: it never calls
|
||||
`mpv_render_context_free` and never tears down on `unrealize`, so nothing stopped
|
||||
the GL context being recreated beneath the render context. That is DR-184 on
|
||||
Android restated — a surface outliving its player.
|
||||
|
||||
Built as a requirement in its own right, not as a fix for a crash we cannot yet
|
||||
reproduce:
|
||||
|
||||
- Render context created on `realize`, freed on `unrealize`, same thread, before
|
||||
the GL context goes away.
|
||||
- The update callback is unregistered **before** the context is freed, so a
|
||||
callback cannot land on a freed context.
|
||||
- Playback teardown and surface teardown are ordered, not racing.
|
||||
|
||||
If the crash recurs after this, it is a different bug and the likeliest cause is
|
||||
out of the search space. If it does not, we needed this anyway.
|
||||
|
||||
### Frame pacing (DR-233)
|
||||
|
||||
Register `mpv_render_context_set_update_callback`; redraw only when it reports a
|
||||
frame ready; call `mpv_render_context_report_swap` after each render.
|
||||
|
||||
Recorded because the failure mode is a trap: driving `queue_render()` off the
|
||||
frame clock every tick without reporting the swap leaves mpv nothing to time
|
||||
against. It looks fine in a window and **judders at fullscreen**, which reads as
|
||||
a compositing or GPU limit and is neither.
|
||||
|
||||
### Renderer-dependent device profile (DR-234)
|
||||
|
||||
`build_device_profile` takes the active video renderer and derives the codec
|
||||
lists from it:
|
||||
|
||||
| Renderer | Video codecs | Audio (video direct play) | Channels |
|
||||
|---|---|---|---|
|
||||
| mpv (desktop native) | `h264,hevc,vp8,vp9,av1,mpeg4` | platform list incl. `ac3,eac3` where the sink can voice it | from the audio route |
|
||||
| WebKitGTK `<video>` | `h264` | webview-decodable set only | 2 |
|
||||
| ExoPlayer (Android) | unchanged | unchanged | unchanged |
|
||||
|
||||
The existing `video_audio_codecs()` narrowing exists because *the webview decodes
|
||||
a narrower audio set than the platform*. With mpv decoding, that no longer
|
||||
applies to the video path — but the multichannel bound still does, since a 5.1
|
||||
track direct-played into a 2-channel sink is silence or inaudible dialogue. Both
|
||||
constraints stay, sourced from the renderer rather than assumed.
|
||||
|
||||
**This is what converts the 7% figure upward** (toward, not necessarily to, the 85% ceiling — see the caveat above), and it is also the change most able to break
|
||||
playback silently — so it lands after compositing is proven, covered by the
|
||||
DR-228 override tests.
|
||||
|
||||
### Deleting the webview video path (DR-235)
|
||||
|
||||
`get_player_status` stops reporting `use_html5_element: true` on desktop;
|
||||
`supports_native_video` becomes true there.
|
||||
|
||||
Deletion is staged, because a path cannot be removed while a shipped platform
|
||||
still needs it:
|
||||
|
||||
| Phase | Linux | Windows | HTML5 video path |
|
||||
|---|---|---|---|
|
||||
| 1 | mpv | HTML5 | alive — Windows needs it |
|
||||
| 2 | mpv | mpv | alive but unreached |
|
||||
| 3 | mpv | mpv | **deleted**, with hls.js |
|
||||
|
||||
Phase 3 is a real phase with its own acceptance criterion, not a "later". The
|
||||
whole maintenance argument for including Windows collapses if the fork survives.
|
||||
|
||||
Android keeps ExoPlayer and keeps the webview as its documented opt-out; the
|
||||
`<audio>` element and the background-audio handoff are untouched throughout.
|
||||
|
||||
**What happens when mpv fails to initialise.** With no HTML5 path there is no
|
||||
silent fallback, and inventing one resurrects what we deleted. The
|
||||
graceful-backend-init principle applies as written: fall back to the no-op
|
||||
backend, emit `backend-init-failed`, and surface a real error rather than a black
|
||||
rectangle. An honest failure beats a hidden downgrade to the transcode we are
|
||||
trying to stop paying for.
|
||||
|
||||
### Hardware decode (DR-236)
|
||||
|
||||
The spike established the load-bearing fact: **hardware decode works through the
|
||||
render API** (`hwdec-current` reported `nvdec-copy` on the discrete GPU), so the
|
||||
direct-play prize is not traded for software decoding.
|
||||
|
||||
Policy is decided from what mpv reports it *selected*, never from what it was
|
||||
asked for:
|
||||
|
||||
- Prefer zero-copy VA-API on the integrated GPU where the driver is present.
|
||||
- `auto` reached for the discrete GPU in **copy-back** mode on a hybrid
|
||||
Intel+NVIDIA laptop — the least efficient hardware path — so `auto` is a
|
||||
fallback, not the default.
|
||||
- `vaapi` silently fell back to software on the spike box because `vainfo` was
|
||||
absent. A missing driver must be detected and logged, not mistaken for a
|
||||
compositing limit.
|
||||
- Log `hwdec-current` at start-up; knowing what was actually chosen is the whole
|
||||
diagnostic value.
|
||||
|
||||
### Windows: what phase 2 actually costs (DR-237)
|
||||
|
||||
Not hidden, because it is the part most likely to be underestimated:
|
||||
|
||||
- **The surface is different code.** WebView2 in an HWND, not GTK. A transparent
|
||||
WebView2 over a native child window is a solved arrangement, but DR-231's
|
||||
Linux surface does not transfer. Everything else does.
|
||||
- **libmpv is currently a Linux-only dependency**, and Windows is
|
||||
**cross-compiled from Linux** via `x86_64-pc-windows-msvc` + `cargo-xwin`. Phase
|
||||
2 must source a Windows libmpv (DLL + import library) into that cross-build and
|
||||
ship the DLL in the NSIS bundle.
|
||||
- **LGPL obligations follow the DLL.** DR-216 already records them for Linux:
|
||||
keep the linkage dynamic, ship libmpv's licence text with any bundle carrying
|
||||
it. The Windows bundle inherits both.
|
||||
- **`bun run test:rust` and CI must still build.** Per the CI rule, any tool this
|
||||
needs goes into the builder image and is pushed — never installed at job time.
|
||||
|
||||
Windows also gains a native *audio* decoder as a side effect, which is what
|
||||
[windows-native-audio-backend.md](windows-native-audio-backend.md) wants and
|
||||
cannot currently have. If that spec lands first, phase 2 inherits its build work
|
||||
and shrinks to the surface.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Android.** Unchanged in every respect.
|
||||
- **macOS.** Not a shipped target. If it becomes one it joins phase 2's shape.
|
||||
- **Audio backends.** mpv already plays audio on Linux; this adds a video
|
||||
renderer beside it. Windows audio is its own spec.
|
||||
- **HDR, tone mapping, multi-window.** Not exercised by the spike at all.
|
||||
- **Re-deciding what stream to play.** DR-225 owns that. If this spec finds
|
||||
itself choosing a URL, something has gone wrong.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
**Phase 1 — Linux**
|
||||
|
||||
- [ ] Tauri's own webview reparents into the overlay (the untested half of G1),
|
||||
on X11 **and** Wayland.
|
||||
- [ ] Video plays, seeks and switches audio track in mpv, with the Svelte
|
||||
controls composited over it and alpha blending intact.
|
||||
- [ ] The render context is freed on `unrealize` and the update callback
|
||||
unregistered before the free; a test demonstrates the ordering.
|
||||
- [ ] A direct-play negotiation returns `DirectPlay` for an hevc source that
|
||||
today returns `Transcode`, and it plays.
|
||||
- [ ] Direct-play rate over the same 40-item sample rises from 7% toward the
|
||||
Android figure. **Record the number.**
|
||||
- [ ] mpv init failure emits `backend-init-failed` and surfaces an error rather
|
||||
than falling back to a transcode.
|
||||
- [ ] `hwdec-current` is logged and is not copy-back where zero-copy is available.
|
||||
- [ ] A soak covering seek, track switch and fullscreen runs clean for an agreed
|
||||
duration. **The spike's SIGSEGV is why this is a criterion.**
|
||||
|
||||
**Phase 2 — Windows**
|
||||
|
||||
- [ ] libmpv links in the `cargo-xwin` cross-build; the DLL and its licence ship
|
||||
in the NSIS bundle; any new tool lives in the builder image, not in a CI step.
|
||||
- [ ] Video plays composited under a transparent WebView2.
|
||||
- [ ] The device profile, lifetime and hwdec code are **reused, not
|
||||
reimplemented** — a reviewer confirms no `cfg!(target_os = "linux")` guards
|
||||
them.
|
||||
|
||||
**Phase 3 — deletion**
|
||||
|
||||
- [ ] `use_html5_element` is false on every desktop platform.
|
||||
- [ ] `hls.js` is gone from `package.json`; `html5Adapter.ts`, `videoLoaderFor`
|
||||
and the `<video>` element are deleted; Android's opt-out and the
|
||||
background-audio `<audio>` path still work.
|
||||
|
||||
**Throughout**
|
||||
|
||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes, and a reviewer confirms no stream decision
|
||||
was reconstructed in the new backend.
|
||||
- [ ] `bindings.ts` regenerated from Rust.
|
||||
- [ ] `bun run traces:validate` passes; coverage stays ≥ the CI ratchet.
|
||||
- [ ] The spike and this spec are folded into
|
||||
[05-platform-backends.md](../architecture/05-platform-backends.md) and both
|
||||
deleted in the same commit.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Rust, pure:** the device profile per renderer — mpv claims hevc, the webview
|
||||
does not, the multichannel bound survives both. The DR-234 table as a
|
||||
table-driven test.
|
||||
- **Rust, pure:** `PlaybackInfo` fixtures that transcode under the webview
|
||||
profile and direct-play under the mpv profile — the direct-play conversion as a unit
|
||||
test, not only as a measurement.
|
||||
- **Rust:** teardown ordering — callback unregistered before context freed, freed
|
||||
before GL context destroyed. Structure it so the ordering is assertable without
|
||||
a live GL context.
|
||||
- **Frontend:** no desktop path selects an HTML5 video adapter. After phase 3,
|
||||
the adapter does not exist and the test goes with it.
|
||||
- **Manual / soak:** the criterion above. The spike's automated fullscreen and
|
||||
resize soaks are reusable and already written.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| mpv video backend + compositing | `UR-080 \| DR-231, IR-033` |
|
||||
| Render-context lifetime binding | `UR-080 \| DR-232` |
|
||||
| Frame pacing | `UR-080 \| DR-233` |
|
||||
| Renderer-dependent device profile | `UR-080, UR-070 \| DR-234` |
|
||||
| Webview video path removed | `UR-080 \| DR-235` |
|
||||
| Hardware-decode policy | `UR-080 \| DR-236` |
|
||||
| Windows surface + cross-build | `UR-080 \| DR-237` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Read the spike before writing a line.** Its three traps and its
|
||||
hardware-decode table are the most valuable things in this directory, and each
|
||||
cost a debugging cycle to find.
|
||||
- **mpv consumes `StreamSelection`; it does not decide.** The transport is on the
|
||||
queue item (DR-230). If you are parsing a URL, stop.
|
||||
- **Guard nothing on `cfg!(target_os = "linux")` that phase 2 will need.** That is
|
||||
the one avoidable mistake here.
|
||||
- The Android backend is the reference for the *shape* of this — transparent
|
||||
webview over a native surface at index 0. Read `05-platform-backends.md`'s
|
||||
Android section for what shipped and what its defects were (DR-184 surface
|
||||
lifetime, DR-194 letterbox).
|
||||
- Do not call sync/blocking APIs from mpv event callbacks that can re-enter the
|
||||
player or hold a lock. The existing deadlock gotchas apply.
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
- This branch is stacked on backend-owned stream selection. Rebase when that
|
||||
merges rather than merging master into it.
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
**Status:** **Run 2026-08-21 — compositing works; G5 carries an open crash.**
|
||||
The compositing claim it set out to test is falsified on Linux. See "Result".
|
||||
This file stays open until the implementation spec exists; ABR is unresolved.
|
||||
This file stays open until the implementation spec exists. **ABR is resolved** —
|
||||
the playlist carries one `EXT-X-STREAM-INF`, so finding 3 is false and there is
|
||||
no adaptation for mpv to lose. The remaining blocker is the unexplained SIGSEGV
|
||||
under G5, which is a lifetime problem, not a compositing one.
|
||||
**Requirements:** none allocated. This spike produces a decision record, not
|
||||
product code — same shape as
|
||||
[playback-backend-unification.md](playback-backend-unification.md), which is
|
||||
@@ -258,10 +261,10 @@ anything.
|
||||
Tauri's existing webview into an overlay. Low risk — the same widgets, one
|
||||
extra reparent — but unproven, and it is the only place Tauri-specific
|
||||
behaviour could still bite.
|
||||
- 🔴 **ABR — finding 3's premise is in doubt.** Finding 3 says mpv would regress
|
||||
streaming quality because "the webview path already has real ABR via hls.js".
|
||||
Three pieces of evidence in this repo suggest that is **not true of the URLs we
|
||||
actually build**:
|
||||
- ✅ **ABR — resolved. Finding 3's premise is false.** Finding 3 said mpv would
|
||||
regress streaming quality because "the webview path already has real ABR via
|
||||
hls.js". Three pieces of evidence in this repo suggested that is **not true of
|
||||
the URLs we actually build**:
|
||||
|
||||
1. `get_video_stream_url` (`repository/online.rs`) requests a *single*
|
||||
rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`.
|
||||
@@ -276,21 +279,52 @@ anything.
|
||||
audio-track switch)". Manual selection by stream re-open is what you build
|
||||
when there is no adaptation, and mpv can do the same thing.
|
||||
|
||||
**The decisive test has not been run** and needs a live server plus an API key:
|
||||
count `#EXT-X-STREAM-INF` lines in a real `master.m3u8`. One line means there
|
||||
is no ABR to lose and this blocker disappears. More than one means finding 3
|
||||
stands and the work below applies.
|
||||
**The decisive test has now been run** (2026-08-21, against the development
|
||||
server, Jellyfin 10.11.5):
|
||||
|
||||
If ABR does turn out to be real, it belongs in **Rust**, not in mpv, and there
|
||||
are three designs in increasing cost: pick the variant at open; re-open at a
|
||||
new bitrate on sustained throughput drops (this is the quality-switch path the
|
||||
app already has, so it is nearly free); or run a local proxy serving mpv a
|
||||
synthesized single-variant playlist while swapping renditions underneath. The
|
||||
middle option is almost certainly sufficient.
|
||||
```
|
||||
curl -s ".../Videos/<itemId>/master.m3u8?…&TranscodingProtocol=hls&…" \
|
||||
| grep -c EXT-X-STREAM-INF
|
||||
1
|
||||
```
|
||||
|
||||
Either way the **direct-play path still does not exist** — every video play
|
||||
currently goes through the HLS transcode endpoint. Building it is the real
|
||||
project; the compositing work proven above is the smaller half.
|
||||
**One line.** The playlist carries a single `EXT-X-STREAM-INF` plus an
|
||||
`EXT-X-IMAGE-STREAM-INF` trickplay entry, which is not a rendition. Jellyfin
|
||||
builds the master playlist from the rendition the request asked for; it does
|
||||
not publish a ladder. So **there is no ABR to lose, and this blocker is
|
||||
closed** — hls.js is serving as an HLS demuxer, exactly as (2) above supposed,
|
||||
and mpv gives up nothing by replacing it.
|
||||
|
||||
Recorded as DR-229 (Won't Do) rather than deleted, because it is a
|
||||
measurement: a server that *does* publish a ladder would change the answer, and
|
||||
the re-negotiation path is the hook that work would build on.
|
||||
|
||||
**The direct-play path now exists.** It did not when this spike was written —
|
||||
every video play went through the HLS transcode endpoint. Backend-owned stream
|
||||
selection (DR-225 … DR-230) built it: Rust negotiates direct play / direct
|
||||
stream / transcode and hands every backend one `StreamSelection` carrying the
|
||||
URL, the transport and the chosen rendition. **That is the contract this
|
||||
implementation consumes** — mpv is a consumer of a decision already made, not a
|
||||
place to re-derive it.
|
||||
|
||||
It also sizes the prize precisely. Measured over the same server, 40 items
|
||||
through a real negotiation per profile:
|
||||
|
||||
| Profile | Direct play |
|
||||
|---|---|
|
||||
| Linux / WebKitGTK — `h264` only, 2ch | **7%** |
|
||||
| Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | **85%** |
|
||||
|
||||
**The 85% is a ceiling, not a shipped result** — it was measured with a
|
||||
profile containing `ac3,eac3`, which the Android device later used for
|
||||
verification does not support.
|
||||
|
||||
The library sampled is ~80% hevc. Linux sits at 7% **solely because the
|
||||
WebKitGTK profile can only claim h264** — not because of anything about the
|
||||
server or the negotiation. mpv decodes hevc, so widening the Linux device
|
||||
profile once mpv renders the picture is what converts that 7% toward the
|
||||
Android figure. That conversion is the actual product of this work; the
|
||||
compositing proven above is the mechanism that permits it.
|
||||
- 🔴 **One unexplained SIGSEGV.** A ~180s
|
||||
run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler
|
||||
-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
(DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and
|
||||
eviction only reclaims the temporary tier), local playback of downloaded media
|
||||
(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138).
|
||||
DR-123 is in progress. Still open: the **player quality selector** and the
|
||||
read-through capture itself — DR-121, DR-122, DR-124, DR-125. The separate
|
||||
settings-level bitrate cap (DR-162, shipped —
|
||||
[01-rust-backend.md](../architecture/01-rust-backend.md#streaming-quality-ladder))
|
||||
covers a *settings-level*
|
||||
ceiling (DR-162), which serves part of UR-070 but is not the per-playback
|
||||
selector specified here.
|
||||
**Requirements:** UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032
|
||||
**UX spec:** player quality selector — needs a `ux-flows.md` section before build
|
||||
DR-123 is in progress. Still open: the read-through capture itself — DR-122,
|
||||
DR-124, DR-125.
|
||||
|
||||
**DR-121 has shipped and left this spec.** The player quality selector, the
|
||||
per-playback bitrate ceiling, and the backend-owned stream decision it needed
|
||||
were built as *backend-owned stream selection* (DR-225 … DR-228) and are
|
||||
described in
|
||||
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection) and
|
||||
[03-data-flow.md](../architecture/03-data-flow.md#video-stream-selection-flow).
|
||||
The settings-level ceiling (DR-162) is the same section. What remains here is the
|
||||
*capture* half only — this spec no longer specifies anything about choosing a
|
||||
bitrate.
|
||||
|
||||
**Requirements:** UR-070, UR-071 → DR-122, DR-123, DR-124, DR-125; IR-032
|
||||
**Related:** the locally-indexed search and downloaded-browse work, both
|
||||
shipped — see
|
||||
[03-data-flow.md](../architecture/03-data-flow.md) and
|
||||
@@ -70,24 +75,16 @@ frontend stores the user's *choice*; Rust decides what that choice resolves to.
|
||||
|
||||
## Design
|
||||
|
||||
### DR-121 — Bitrate selection in the player
|
||||
### DR-121 — moved out (shipped)
|
||||
|
||||
The player exposes the qualities Rust reports for the current item. Changing it
|
||||
re-negotiates the stream URL at the new quality and resumes at the current
|
||||
position. This is a deliberate, user-initiated interruption — a brief rebuffer is
|
||||
expected and acceptable, unlike the involuntary swap the earlier design would
|
||||
have needed.
|
||||
Bitrate selection in the player shipped as DR-225 … DR-228; see
|
||||
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection).
|
||||
|
||||
Constraints that must not be broken:
|
||||
|
||||
- On Linux, video playback must keep using the HLS `master.m3u8` URL. CLAUDE.md
|
||||
records that returning `stream.mp4` means transcoded playback never starts.
|
||||
A quality change re-negotiates *within* HLS.
|
||||
- The quality→transcode-parameter mapping already exists in
|
||||
`get_video_download_url` ([online.rs:1702-1717](../../src-tauri/src/repository/online.rs#L1702-L1717)).
|
||||
Playback must call into the same mapping. Two copies of that table will drift.
|
||||
- Track selection (audio/subtitle) already survives a stream re-negotiation
|
||||
elsewhere in the player; a quality change must preserve it too.
|
||||
The one constraint here that the capture work still has to respect: a quality
|
||||
change re-negotiates **within HLS**. Returning a progressive `stream.mp4` for a
|
||||
transcode means playback never starts, because the server encodes the whole file
|
||||
before serving a byte (DR-140). That is why DR-122 below abandons a capture on a
|
||||
quality change rather than trying to splice one.
|
||||
|
||||
### DR-122 — The playback path is ephemeral
|
||||
|
||||
@@ -212,7 +209,6 @@ codec taxonomy in `src/`; the selector's remembered choice is a view preference.
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| Quality selector + re-negotiation | `// TRACES: UR-070 \| DR-121` |
|
||||
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
|
||||
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
|
||||
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
|
||||
|
||||
+7581
-6063
File diff suppressed because it is too large
Load Diff
Generated
+2
@@ -2191,11 +2191,13 @@ dependencies = [
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"getrandom 0.2.16",
|
||||
"gtk",
|
||||
"hostname",
|
||||
"jni 0.21.1",
|
||||
"keyring",
|
||||
"libc",
|
||||
"libmpv",
|
||||
"libmpv-sys",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"rand 0.8.7",
|
||||
|
||||
@@ -114,6 +114,25 @@ libc = "0.2"
|
||||
# than changing it. To take upstream fixes, bump this deliberately.
|
||||
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
|
||||
|
||||
# The raw FFI bindings behind `libmpv`, pinned to the *same* revision so the two
|
||||
# can never describe different ABIs.
|
||||
#
|
||||
# Needed because the safe crate's `render` module is an empty stub at this
|
||||
# revision — the render API (`mpv_render_context_create` and friends) exists only
|
||||
# in the sys bindings, which do carry all of it. `Mpv::ctx` is public, so the
|
||||
# render context can be built over the same handle the safe wrapper drives. This
|
||||
# is what makes native video reachable *without* first completing the libmpv2
|
||||
# migration, which the spike's use of `libmpv2-sys` had implied was a
|
||||
# prerequisite.
|
||||
#
|
||||
# TRACES: UR-080 | DR-230, IR-033
|
||||
libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
|
||||
|
||||
# Same major as the one Tauri/wry already resolve, so `gtk_window()` and
|
||||
# `default_vbox()` hand back types this crate can name rather than a second,
|
||||
# incompatible GTK.
|
||||
gtk = "0.18"
|
||||
|
||||
# JNI for Android ExoPlayer integration
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::player::{
|
||||
};
|
||||
use crate::repository::{
|
||||
types::{GetItemsOptions, ImageOptions, ImageType},
|
||||
MediaRepository,
|
||||
MediaRepository, StreamSelection,
|
||||
};
|
||||
use crate::settings::VideoSettings;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
@@ -179,6 +179,18 @@ pub struct PlayItemRequest {
|
||||
pub video_codec: String,
|
||||
/// Whether the video requires server-side transcoding
|
||||
pub needs_transcoding: bool,
|
||||
/// How this item's stream is fetched, as the backend decided it.
|
||||
///
|
||||
/// Carried on the queue item so a later seek/reload does not have to guess.
|
||||
/// `None` for items queued by a path that never negotiated (audio tracks,
|
||||
/// direct URLs) and for anything queued before this field existed, where the
|
||||
/// caller falls back to `needs_transcoding` — every transcode this app
|
||||
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
||||
///
|
||||
/// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
|
||||
#[serde(default)]
|
||||
pub transport: Option<crate::repository::Transport>,
|
||||
|
||||
/// Optional now-playing metadata. Used by the background-audio handoff so the
|
||||
/// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
||||
/// existing video-only callers need not send them.
|
||||
@@ -317,9 +329,15 @@ pub enum VideoSeekResponse {
|
||||
},
|
||||
/// Reload stream from new position (transcoded non-HLS)
|
||||
ReloadStream {
|
||||
/// New stream URL starting at seek position
|
||||
new_url: String,
|
||||
/// Position offset to track (for display purposes)
|
||||
/// What to open, and how — transport included, so the frontend picks
|
||||
/// its loader from a tagged enum rather than by searching the URL for
|
||||
/// `.m3u8`. TRACES: UR-079 | DR-225
|
||||
selection: StreamSelection,
|
||||
/// `seek_offset` carries the position to RESUME AT, not a base to add to
|
||||
/// the element's clock. The reloaded stream starts at the item's zero —
|
||||
/// a position on an HLS playlist makes the server 400 every segment
|
||||
/// behind it (DR-181) — so the adapter reaches the position by seeking
|
||||
/// the element and leaves the transcode offset at zero.
|
||||
seek_offset: f64,
|
||||
},
|
||||
}
|
||||
@@ -335,8 +353,8 @@ pub enum AudioTrackSwitchResponse {
|
||||
},
|
||||
/// HTML5 needs to reload stream with new audio track
|
||||
ReloadStream {
|
||||
/// New stream URL with selected audio track
|
||||
new_url: String,
|
||||
/// What to open, and how. TRACES: UR-079 | DR-225
|
||||
selection: StreamSelection,
|
||||
/// Current position to resume from
|
||||
position: f64,
|
||||
},
|
||||
@@ -351,15 +369,31 @@ pub enum AudioTrackSwitchResponse {
|
||||
#[derive(specta::Type, Debug, Serialize)]
|
||||
#[serde(tag = "strategy", rename_all = "camelCase")]
|
||||
pub enum StreamQualityResponse {
|
||||
/// The native backend was reloaded here; nothing left for the frontend.
|
||||
/// The native backend was reloaded here; nothing left for the frontend to
|
||||
/// *do* — but it still has to be told what was negotiated.
|
||||
///
|
||||
/// This carried only a position at first, which left the picker on Android
|
||||
/// pinned to the rendition of the *first* stream: the UI derives the rung in
|
||||
/// force from the selection it holds, nothing replaced that selection on the
|
||||
/// native path, and a transcode always has a rendition — so the fallback
|
||||
/// that would have used the requested value was never reached. The stream
|
||||
/// changed and the menu did not.
|
||||
///
|
||||
/// TRACES: UR-074, UR-079 | DR-226, DR-227
|
||||
Native {
|
||||
/// What the backend actually opened, so the UI reflects it rather than
|
||||
/// assuming the request was honoured verbatim.
|
||||
selection: StreamSelection,
|
||||
/// Position playback resumed at.
|
||||
position: f64,
|
||||
},
|
||||
/// HTML5 must reload its element with this URL.
|
||||
/// HTML5 must reload its element with this selection.
|
||||
ReloadStream {
|
||||
/// New stream URL, already transcoded to the requested ceiling.
|
||||
new_url: String,
|
||||
/// What to open, and how — already negotiated against the requested
|
||||
/// ceiling. Carries `available` too, so a picker opened after a quality
|
||||
/// change still describes the source correctly.
|
||||
/// TRACES: UR-070, UR-079 | DR-225, DR-227
|
||||
selection: StreamSelection,
|
||||
/// Position to resume from.
|
||||
position: f64,
|
||||
},
|
||||
@@ -415,6 +449,8 @@ pub(super) async fn create_media_item(
|
||||
source,
|
||||
video_codec: Some(req.video_codec),
|
||||
needs_transcoding: req.needs_transcoding,
|
||||
// The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-230
|
||||
transport: req.transport,
|
||||
video_width: None, // Not available from video-only request
|
||||
video_height: None, // Not available from video-only request
|
||||
// Sideloaded subtitles, in the order the frontend sent them — that order
|
||||
@@ -663,6 +699,14 @@ pub async fn player_play_item(
|
||||
item.title, item.stream_url
|
||||
);
|
||||
|
||||
// A ceiling chosen from the in-player picker belongs to the playback it was
|
||||
// chosen for. Starting a different item returns to the device default —
|
||||
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
|
||||
// session, which is the defect DR-226 exists to close.
|
||||
//
|
||||
// TRACES: UR-074, UR-079 | DR-226
|
||||
crate::repository::online::clear_playback_quality_override();
|
||||
|
||||
// Create media item, checking for local download first
|
||||
let media_item = create_media_item(item, Some(&db)).await?;
|
||||
|
||||
@@ -762,6 +806,8 @@ pub async fn player_enter_background_audio(
|
||||
// create_media_item() because that hardcodes MediaType::Video; background
|
||||
// audio must be Audio so no video decode is started.
|
||||
let media_item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: item.id.clone(),
|
||||
title: item.title.clone(),
|
||||
name: Some(item.title.clone()),
|
||||
@@ -852,7 +898,7 @@ pub async fn player_enter_background_audio(
|
||||
/// playing there is nothing to pause, and an error would make the frontend
|
||||
/// handle a case that is not a failure.
|
||||
///
|
||||
/// TRACES: UR-040, UR-041 | DR-224 | UT-211
|
||||
/// TRACES: UR-040, UR-041 | DR-225 | UT-212
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_background_action(
|
||||
@@ -935,6 +981,14 @@ pub async fn player_play_queue(
|
||||
request.shuffle
|
||||
);
|
||||
|
||||
// A ceiling chosen from the in-player picker belongs to the playback it was
|
||||
// chosen for. Starting a different item returns to the device default —
|
||||
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
|
||||
// session, which is the defect DR-226 exists to close.
|
||||
//
|
||||
// TRACES: UR-074, UR-079 | DR-226
|
||||
crate::repository::online::clear_playback_quality_override();
|
||||
|
||||
// Handle shuffle first
|
||||
if request.shuffle {
|
||||
let controller = player.0.lock().await;
|
||||
@@ -1347,7 +1401,7 @@ pub async fn player_seek(
|
||||
///
|
||||
/// This command analyzes the current video stream and automatically chooses
|
||||
/// the best seeking strategy:
|
||||
/// - HLS streams (.m3u8): Use native seeking
|
||||
/// - HLS streams: Use native seeking
|
||||
/// - Direct play streams: Use native seeking
|
||||
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
||||
///
|
||||
@@ -1377,7 +1431,7 @@ pub async fn player_seek_video(
|
||||
|
||||
// Get current playing item to analyze stream characteristics
|
||||
// Clone what we need to avoid holding locks across await points
|
||||
let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = {
|
||||
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
|
||||
let controller = player.0.lock().await;
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
@@ -1393,18 +1447,27 @@ pub async fn player_seek_video(
|
||||
.ok_or("Current video has no Jellyfin ID")?
|
||||
.to_string();
|
||||
|
||||
let (stream_url, is_local_file) = match ¤t_item.source {
|
||||
MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false),
|
||||
MediaSource::Local { .. } => (String::new(), true),
|
||||
MediaSource::DirectUrl { url } => (url.clone(), false),
|
||||
};
|
||||
// The URL itself is no longer read here: the seek strategy now comes
|
||||
// from the item's own `transport`, not from inspecting the string.
|
||||
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
|
||||
|
||||
let needs_trans = current_item.needs_transcoding;
|
||||
(needs_trans, jellyfin_id, stream_url, is_local_file)
|
||||
let transport = current_item.transport;
|
||||
(needs_trans, jellyfin_id, is_local_file, transport)
|
||||
}; // Locks are dropped here
|
||||
|
||||
// Determine seek strategy using the testable helper function
|
||||
let is_hls = stream_url.contains(".m3u8");
|
||||
// The transport comes from the backend's own decision, not from searching
|
||||
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
|
||||
// queued without one fall back to `needs_transcoding`, which is exact:
|
||||
// every transcode this app requests is HLS (DR-140).
|
||||
//
|
||||
// TRACES: UR-004, UR-079 | DR-225, DR-230
|
||||
let is_hls = match transport {
|
||||
Some(crate::repository::Transport::Hls) => true,
|
||||
Some(crate::repository::Transport::Progressive)
|
||||
| Some(crate::repository::Transport::LocalFile) => false,
|
||||
None => needs_transcoding,
|
||||
};
|
||||
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
|
||||
|
||||
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
|
||||
@@ -1428,29 +1491,22 @@ pub async fn player_seek_video(
|
||||
// Transcoded non-HLS with HTML5 - frontend handles stream reload
|
||||
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
|
||||
|
||||
let new_url = repository
|
||||
.get_video_stream_url(
|
||||
let selection = repository
|
||||
.get_stream_selection(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
|
||||
info!(
|
||||
"[player_seek_video] Got new stream URL for position {}",
|
||||
position
|
||||
"[player_seek_video] Selected {:?} over {:?} for position {}",
|
||||
selection.playback_kind, selection.transport, position
|
||||
);
|
||||
|
||||
// `seek_offset` carries the position to RESUME AT, not a base to add
|
||||
// to the element's clock. The reloaded stream starts at the item's
|
||||
// zero — a position on an HLS playlist makes the server 400 every
|
||||
// segment behind it (DR-181) — so the adapter reaches the position by
|
||||
// seeking the element and leaves the transcode offset at zero. The
|
||||
// field keeps its name only because renaming it means regenerating
|
||||
// the specta bindings; `reloadSource` documents the contract.
|
||||
Ok(VideoSeekResponse::ReloadStream {
|
||||
new_url,
|
||||
selection,
|
||||
seek_offset: position,
|
||||
})
|
||||
}
|
||||
@@ -1458,16 +1514,17 @@ pub async fn player_seek_video(
|
||||
// Transcoded non-HLS with native backend - backend handles stream reload
|
||||
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
|
||||
|
||||
let new_url = repository
|
||||
.get_video_stream_url(
|
||||
let selection = repository
|
||||
.get_stream_selection(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
let new_url = selection.url.clone();
|
||||
|
||||
info!("[player_seek_video] Got new stream URL, handling reload internally");
|
||||
info!("[player_seek_video] Got new selection, handling reload internally");
|
||||
|
||||
// Stop current playback
|
||||
{
|
||||
@@ -1568,20 +1625,25 @@ pub async fn player_switch_audio_track(
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Get new stream URL with selected audio track. It starts at zero — an
|
||||
// HLS playlist cannot carry a position (DR-181) — and `position` below
|
||||
// tells the frontend where to seek the reloaded element back to.
|
||||
let new_url = repository
|
||||
.get_video_stream_url(
|
||||
// Select a stream carrying the chosen audio track. It starts at zero —
|
||||
// an HLS playlist cannot carry a position (DR-181) — and `position`
|
||||
// below tells the frontend where to seek the reloaded element back to.
|
||||
//
|
||||
// Pinning a track is itself a reason the source cannot be direct-played:
|
||||
// the file has one default track and the viewer asked for another, so
|
||||
// the negotiation returns a transcode. That decision lives in
|
||||
// `decide_playback_kind`, not here.
|
||||
let selection = repository
|
||||
.get_stream_selection(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
Some(stream_index),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
|
||||
Ok(AudioTrackSwitchResponse::ReloadStream {
|
||||
new_url,
|
||||
selection,
|
||||
position: current_position.unwrap_or(0.0),
|
||||
})
|
||||
} else {
|
||||
@@ -1604,23 +1666,26 @@ pub async fn player_switch_audio_track(
|
||||
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||
/// native backend is reloaded here.
|
||||
///
|
||||
/// The change applies to this playback *and* to everything started afterwards
|
||||
/// (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||
/// the in-player picker is a "this film, this connection" control, and the
|
||||
/// durable default belongs to Settings. `player_set_video_settings` is the one
|
||||
/// that writes to the database.
|
||||
/// The change applies to **this playback only**. The in-player picker is a
|
||||
/// "this film, this connection" control and its doc has always said so, but it
|
||||
/// used to be implemented by writing the process-wide ceiling — so choosing
|
||||
/// 2 Mbps to get one awkward film moving silently capped every video played
|
||||
/// afterwards for the rest of the process, with the Settings screen still
|
||||
/// showing the old value and nothing in the UI admitting the change. It now
|
||||
/// sets a per-playback override that the next item clears; the durable default
|
||||
/// belongs to Settings, and `player_set_video_settings` is the one that writes
|
||||
/// to the database.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
/// TRACES: UR-074, UR-079 | DR-162, DR-226
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// Three of the nine arguments are Tauri `State<'_, _>` injections, not caller
|
||||
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
|
||||
// input. Folding the rest into a struct would change the IPC contract and the
|
||||
// generated TypeScript for no readability gain.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn player_set_stream_quality(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
video_settings: State<'_, VideoSettingsWrapper>,
|
||||
repository_handle: String,
|
||||
quality: crate::settings::StreamingQuality,
|
||||
use_html5: bool,
|
||||
@@ -1657,25 +1722,50 @@ pub async fn player_set_stream_quality(
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Set the ceiling *before* building the URL — the builder reads it.
|
||||
crate::repository::online::set_streaming_quality(quality);
|
||||
{
|
||||
let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
|
||||
settings.streaming_quality = quality;
|
||||
}
|
||||
// Set the ceiling *before* negotiating — the negotiation and every URL
|
||||
// builder resolve through `effective_streaming_quality`, and they have to
|
||||
// agree or the cap leaks (a negotiation authorising a direct play the URL
|
||||
// builder then never gets to constrain).
|
||||
//
|
||||
// Deliberately the *override*, not the device default: see the doc above.
|
||||
// TRACES: UR-074, UR-079 | DR-226
|
||||
crate::repository::online::set_playback_quality_override(quality);
|
||||
|
||||
let position = current_position.unwrap_or(0.0);
|
||||
let new_url = repository
|
||||
.get_video_stream_url(
|
||||
// Where to resume. `current_position` is the *element's* clock, which only
|
||||
// the webview path has — on a native backend there is no `<video>` and the
|
||||
// frontend correctly sends null, so trusting it there resumed every quality
|
||||
// change from zero.
|
||||
//
|
||||
// The player is the authority on position (it is the authority on all
|
||||
// playback state); asking the DOM for it and falling back to 0 inverted
|
||||
// that. Fall back to what the controller reports instead.
|
||||
//
|
||||
// TRACES: UR-005, UR-074 | DR-226
|
||||
// The guard is bound inside the arm's block so it is dropped before the
|
||||
// reload below takes the same lock. This codebase has been bitten by a
|
||||
// MutexGuard living longer than the expression that produced it.
|
||||
let position = match current_position {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let controller = player.0.lock().await;
|
||||
controller.absolute_position()
|
||||
}
|
||||
};
|
||||
let selection = repository
|
||||
.get_stream_selection(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
let new_url = selection.url.clone();
|
||||
|
||||
if use_html5 {
|
||||
return Ok(StreamQualityResponse::ReloadStream { new_url, position });
|
||||
return Ok(StreamQualityResponse::ReloadStream {
|
||||
selection,
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||
@@ -1705,7 +1795,10 @@ pub async fn player_set_stream_quality(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StreamQualityResponse::Native { position })
|
||||
Ok(StreamQualityResponse::Native {
|
||||
selection,
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the active audio track on a native backend directly.
|
||||
@@ -2208,6 +2301,8 @@ pub async fn player_play_album_track(
|
||||
|
||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||
let media_item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
@@ -2353,6 +2448,14 @@ pub async fn player_play_tracks(
|
||||
repository_handle: String,
|
||||
request: PlayTracksRequest,
|
||||
) -> Result<PlayerStatus, String> {
|
||||
// A ceiling chosen from the in-player picker belongs to the playback it was
|
||||
// chosen for. Starting a different item returns to the device default —
|
||||
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
|
||||
// session, which is the defect DR-226 exists to close.
|
||||
//
|
||||
// TRACES: UR-074, UR-079 | DR-226
|
||||
crate::repository::online::clear_playback_quality_override();
|
||||
|
||||
info!(
|
||||
"player_play_tracks called: {} tracks, start_index={}, shuffle={}",
|
||||
request.track_ids.len(),
|
||||
@@ -2404,6 +2507,8 @@ pub async fn player_play_tracks(
|
||||
// Transform to MediaItem with frontend-compatible fields
|
||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||
let media_item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
@@ -3210,6 +3315,8 @@ mod tests {
|
||||
let db = DatabaseWrapper(Mutex::new(database));
|
||||
|
||||
let make_item = |id: &str| MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: id.to_string(),
|
||||
title: id.to_string(),
|
||||
name: None,
|
||||
|
||||
@@ -198,6 +198,8 @@ pub async fn player_add_track_by_id(
|
||||
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||
let media_item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
@@ -317,6 +319,8 @@ pub async fn player_add_tracks_by_ids(
|
||||
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||
let media_item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::domain::rank_search_results;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::repository::{
|
||||
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||
OnlineRepository,
|
||||
OnlineRepository, StreamSelection,
|
||||
};
|
||||
|
||||
/// Repository handle manager
|
||||
@@ -606,6 +606,35 @@ pub async fn repository_get_video_stream_url(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Decide what stream to play for a video, and describe it.
|
||||
///
|
||||
/// Replaces `repository_get_video_stream_url` for playback. The returned
|
||||
/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
|
||||
/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
|
||||
/// carries the quality ladder as it applies to *this* source, so the picker can
|
||||
/// stop offering rungs that produce the same bytes as Original.
|
||||
///
|
||||
/// No start-position parameter, for the same reason as the URL builder: a
|
||||
/// position on an HLS playlist is copied onto every segment URI and the server
|
||||
/// rejects each with `400` (DR-181). Callers resume by seeking after load.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_stream_selection(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
media_source_id: Option<String>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<StreamSelection, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
||||
///
|
||||
/// TRACES: UR-040 | JA-032 | UT-061
|
||||
|
||||
@@ -104,6 +104,30 @@ pub fn media_local_url(
|
||||
.ok_or_else(|| "Local media server is not running".to_string())
|
||||
}
|
||||
|
||||
/// The stream selection for a downloaded file.
|
||||
///
|
||||
/// The local-playback counterpart to `repository_get_stream_selection`. A file
|
||||
/// on disk needs no negotiation — it is a direct play over a local transport,
|
||||
/// with no quality ladder, because nothing about it can be re-negotiated — but
|
||||
/// the *frontend must not be the one to say so*. It gets the same
|
||||
/// [`StreamSelection`] shape as a streamed source so the player has one contract
|
||||
/// to consume rather than two, and so no caller has to infer a transport from a
|
||||
/// loopback URL.
|
||||
///
|
||||
/// TRACES: UR-071, UR-079 | DR-225
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn media_local_selection(
|
||||
server: State<crate::media_server::MediaServerWrapper>,
|
||||
path: String,
|
||||
) -> Result<crate::repository::StreamSelection, String> {
|
||||
server
|
||||
.0
|
||||
.as_ref()
|
||||
.map(|s| crate::repository::StreamSelection::local_file(s.url_for(&path)))
|
||||
.ok_or_else(|| "Local media server is not running".to_string())
|
||||
}
|
||||
|
||||
/// Get storage directory path (parent directory of the database file)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -96,6 +96,7 @@ use commands::{
|
||||
lms_unsync_player,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_selection,
|
||||
media_local_url,
|
||||
offline_get_items,
|
||||
offline_is_available,
|
||||
@@ -224,6 +225,7 @@ use commands::{
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_episodes,
|
||||
repository_get_similar_items,
|
||||
repository_get_stream_selection,
|
||||
repository_get_subtitle_url,
|
||||
repository_get_video_download_url,
|
||||
repository_get_video_stream_url,
|
||||
@@ -898,6 +900,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_url,
|
||||
media_local_selection,
|
||||
start_download,
|
||||
enqueue_download,
|
||||
enqueue_video_downloads,
|
||||
@@ -984,6 +987,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_search,
|
||||
repository_get_playback_info,
|
||||
repository_get_video_stream_url,
|
||||
repository_get_stream_selection,
|
||||
repository_get_audio_stream_url,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
repository_get_live_tv_channels,
|
||||
@@ -1206,6 +1210,56 @@ pub fn run() {
|
||||
// listened for on the frontend via the generated bindings.
|
||||
builder.mount_events(app);
|
||||
|
||||
// Native video surface: put a GL area under Tauri's webview so mpv
|
||||
// can draw beneath the controls (UR-080 / DR-231).
|
||||
//
|
||||
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
|
||||
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
|
||||
// walks a hard-coded two-hop path on every button press in the
|
||||
// webview:
|
||||
//
|
||||
// webview.parent() // "This one should be GtkBox"
|
||||
// .parent() // ...and this one the GtkWindow
|
||||
// .downcast::<gtk::Window>().unwrap()
|
||||
//
|
||||
// Wrapping the webview in a GtkOverlay makes that chain
|
||||
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
|
||||
// panic is non-unwinding it aborts the process. The decoration check
|
||||
// that would otherwise make this handler inert runs *after* the
|
||||
// unwrap, so no window configuration avoids it.
|
||||
//
|
||||
// This is the "only place Tauri-specific behaviour could still bite"
|
||||
// that the spike named as the untested half of G1. It bites. The
|
||||
// surface attaches perfectly and then dies on interaction, so
|
||||
// "attached successfully" in the log is not the gate — a click is.
|
||||
//
|
||||
// Kept behind an env var rather than deleted so the next attempt has
|
||||
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
|
||||
//
|
||||
// TRACES: UR-080 | DR-231
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var("JELLYTAU_NATIVE_VIDEO").as_deref() == Ok("1") {
|
||||
use tauri::Manager;
|
||||
log::warn!(
|
||||
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
|
||||
video surface; the app will abort on the first click until \
|
||||
the widget-tree shape is solved (DR-231)"
|
||||
);
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match window.default_vbox() {
|
||||
Ok(vbox) => match crate::player::video_surface::attach(&vbox) {
|
||||
Ok(_surface) => {
|
||||
info!("[INIT] Native video surface attached");
|
||||
}
|
||||
Err(e) => log::warn!("[INIT] Native video surface unavailable: {e}"),
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("[INIT] No GTK vbox for the main window: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In-app update, desktop only.
|
||||
//
|
||||
// Registered here rather than in the builder chain above because a
|
||||
|
||||
@@ -1125,6 +1125,8 @@ mod tests {
|
||||
|
||||
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: id.to_string(),
|
||||
title: format!("Track {}", id),
|
||||
name: Some(format!("Track {}", id)),
|
||||
@@ -1157,6 +1159,8 @@ mod tests {
|
||||
|
||||
fn create_test_item_local(id: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: id.to_string(),
|
||||
title: format!("Local Track {}", id),
|
||||
name: Some(format!("Local Track {}", id)),
|
||||
|
||||
@@ -377,6 +377,8 @@ mod tests {
|
||||
|
||||
// Create a test media item
|
||||
let media = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
@@ -436,6 +438,8 @@ mod tests {
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
@@ -489,6 +493,8 @@ mod tests {
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
|
||||
@@ -115,6 +115,18 @@ pub struct MediaItem {
|
||||
/// Whether the video requires server-side transcoding
|
||||
#[serde(default)]
|
||||
pub needs_transcoding: bool,
|
||||
/// How this item's stream is fetched, as the backend decided it.
|
||||
///
|
||||
/// Carried on the queue item so a later seek/reload does not have to guess.
|
||||
/// `None` for items queued by a path that never negotiated (audio tracks,
|
||||
/// direct URLs) and for anything queued before this field existed, where the
|
||||
/// caller falls back to `needs_transcoding` — every transcode this app
|
||||
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
||||
///
|
||||
/// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
|
||||
#[serde(default)]
|
||||
pub transport: Option<crate::repository::Transport>,
|
||||
|
||||
/// Video width in pixels
|
||||
#[serde(default)]
|
||||
pub video_width: Option<u32>,
|
||||
@@ -360,6 +372,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_creation_minimal() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-1".to_string(),
|
||||
title: "Test Item".to_string(),
|
||||
name: None,
|
||||
@@ -396,6 +410,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_jellyfin_id() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-2".to_string(),
|
||||
title: "Test".to_string(),
|
||||
name: None,
|
||||
@@ -431,6 +447,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_jellyfin_id_local() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-3".to_string(),
|
||||
title: "Local".to_string(),
|
||||
name: None,
|
||||
@@ -466,6 +484,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_jellyfin_id_direct_url() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-4".to_string(),
|
||||
title: "Direct".to_string(),
|
||||
name: None,
|
||||
@@ -508,6 +528,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "item-subs".to_string(),
|
||||
title: "With Subs".to_string(),
|
||||
name: None,
|
||||
@@ -543,6 +565,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_media_item_serialization() {
|
||||
let item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "serial-item".to_string(),
|
||||
title: "Serial Test".to_string(),
|
||||
name: Some("Name".to_string()),
|
||||
|
||||
@@ -24,6 +24,14 @@ pub mod android;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod mpv_backend;
|
||||
|
||||
/// The native video surface mpv renders into (UR-080 / DR-231).
|
||||
///
|
||||
/// Linux-gated for now because the surface is GTK. Everything *around* it — the
|
||||
/// render context, its lifetime, frame pacing, the device profile — is
|
||||
/// deliberately not, so Windows reuses it behind its own surface.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod video_surface;
|
||||
|
||||
// Platforms with no native audio backend (e.g. Windows) render audio-only
|
||||
// playback through a webview <audio> element, mirroring how all video renders.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
@@ -1897,6 +1905,8 @@ impl PlayerController {
|
||||
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
|
||||
|
||||
let media_item = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: next.id.clone(),
|
||||
title: next.name.clone(),
|
||||
name: Some(next.name.clone()),
|
||||
@@ -2580,6 +2590,8 @@ mod tests {
|
||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||
(0..count)
|
||||
.map(|i| MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: format!("item_{}", i),
|
||||
title: format!("Track {}", i + 1),
|
||||
name: Some(format!("Track {}", i + 1)),
|
||||
@@ -3792,6 +3804,8 @@ mod tests {
|
||||
|
||||
// Queue holds the episode that just finished playing
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
media_type: MediaType::Video,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep1.mkv".to_string(),
|
||||
@@ -3827,6 +3841,8 @@ mod tests {
|
||||
|
||||
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio, // audio-only handoff, not Video
|
||||
series_id: Some("series1".to_string()),
|
||||
@@ -3943,6 +3959,8 @@ mod tests {
|
||||
|
||||
// Currently playing: ep2 handed off to audio-only background playback.
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -3978,6 +3996,8 @@ mod tests {
|
||||
/// URL carrying the handoff position.
|
||||
fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -3998,6 +4018,8 @@ mod tests {
|
||||
/// handoff point.
|
||||
fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
source: MediaSource::Local {
|
||||
file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
|
||||
jellyfin_item_id: Some("ep2".to_string()),
|
||||
@@ -4682,6 +4704,8 @@ mod tests {
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Video,
|
||||
@@ -4717,6 +4741,8 @@ mod tests {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let episode = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
media_type: MediaType::Video,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep1.mkv".to_string(),
|
||||
|
||||
@@ -541,6 +541,8 @@ mod tests {
|
||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||
(0..count)
|
||||
.map(|i| MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: format!("item_{}", i),
|
||||
title: format!("Track {}", i + 1),
|
||||
name: Some(format!("Track {}", i + 1)),
|
||||
|
||||
@@ -232,6 +232,8 @@ mod tests {
|
||||
|
||||
fn create_test_audio_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
@@ -263,6 +265,8 @@ mod tests {
|
||||
|
||||
fn create_test_movie_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
|
||||
@@ -316,6 +316,8 @@ mod tests {
|
||||
// Helper function to create test MediaItem instances
|
||||
fn create_test_media_item(id: &str, title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
name: None,
|
||||
|
||||
@@ -258,6 +258,8 @@ mod tests {
|
||||
/// `StartTimeTicks` is the handoff point.
|
||||
fn handoff_item() -> MediaItem {
|
||||
MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
id: "ep2".to_string(),
|
||||
title: "Episode 2".to_string(),
|
||||
name: None,
|
||||
@@ -303,6 +305,8 @@ mod tests {
|
||||
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
|
||||
// ranges, so ExoPlayer resumes it where the load failed.
|
||||
let track = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
..handoff_item()
|
||||
};
|
||||
@@ -314,6 +318,8 @@ mod tests {
|
||||
// An HLS playlist declares its segments, so a failed segment load is
|
||||
// retried at that segment, not at the start of the episode.
|
||||
let video = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
media_type: MediaType::Video,
|
||||
..handoff_item()
|
||||
};
|
||||
@@ -324,6 +330,8 @@ mod tests {
|
||||
fn test_downloaded_episode_keeps_the_players_retry() {
|
||||
// A local file has no length problem and no network to lose.
|
||||
let local = MediaItem {
|
||||
// Audio and direct-URL items never negotiate a transport.
|
||||
transport: None,
|
||||
source: MediaSource::Local {
|
||||
file_path: PathBuf::from("/data/ep2.mkv"),
|
||||
jellyfin_item_id: Some("ep2".to_string()),
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! The native video surface: a GL area beneath Tauri's own webview.
|
||||
//!
|
||||
//! This is the desktop counterpart of the Android arrangement — a native
|
||||
//! renderer at the bottom of the stack with a transparent webview drawn over it,
|
||||
//! so the Svelte controls composite on top of moving video.
|
||||
//!
|
||||
//! The spike that authorised this built its *own* `GtkOverlay` and proved mpv
|
||||
//! renders into it on X11 and Wayland. What it could not prove is the step this
|
||||
//! module exists for: taking the overlay Tauri already built and reparenting the
|
||||
//! real webview into it. Same widgets, one extra move, and the only place
|
||||
//! Tauri-specific behaviour can still bite — which is why it is gate one.
|
||||
//!
|
||||
//! TRACES: UR-080 | DR-231, IR-033
|
||||
|
||||
use gtk::prelude::*;
|
||||
use log::{info, warn};
|
||||
|
||||
/// The widgets that make up the video surface, kept together because their
|
||||
/// lifetimes are bound: the render context (added next) is created when the GL
|
||||
/// area realizes and must be freed before it unrealizes — DR-232.
|
||||
pub struct VideoSurface {
|
||||
/// The GL area mpv renders into. Main child of the overlay, so it sits
|
||||
/// *under* everything else.
|
||||
#[allow(dead_code)]
|
||||
gl_area: gtk::GLArea,
|
||||
/// The overlay holding the GL area and the webview.
|
||||
#[allow(dead_code)]
|
||||
overlay: gtk::Overlay,
|
||||
}
|
||||
|
||||
impl VideoSurface {
|
||||
// Consumed by the render context, which binds to the GL area on `realize`
|
||||
// and is freed on `unrealize` (DR-232). Held here from the moment the
|
||||
// surface exists so that binding has something to attach to.
|
||||
#[allow(dead_code)]
|
||||
/// The GL area, for the render context to bind to.
|
||||
pub fn gl_area(&self) -> >k::GLArea {
|
||||
&self.gl_area
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// The overlay, for teardown.
|
||||
pub fn overlay(&self) -> >k::Overlay {
|
||||
&self.overlay
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a surface could not be attached.
|
||||
///
|
||||
/// One variant, because there is exactly one way this fails that is not already
|
||||
/// reported by Tauri itself: the window exists and has a vbox, but the vbox is
|
||||
/// not shaped the way Tauri has always shaped it.
|
||||
#[derive(Debug)]
|
||||
pub enum SurfaceError {
|
||||
/// The vbox held no webview to reparent — Tauri's layout has changed.
|
||||
NoWebviewChild,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SurfaceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SurfaceError::NoWebviewChild => write!(
|
||||
f,
|
||||
"Tauri's default vbox had no child to reparent — its window layout has changed"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SurfaceError {}
|
||||
|
||||
/// Build the overlay and move Tauri's webview on top of it.
|
||||
///
|
||||
/// Tauri's Linux window is an `ApplicationWindow` holding a single vertical
|
||||
/// `gtk::Box` (`default_vbox`), with the webview packed into it. This takes that
|
||||
/// webview out, puts a `GtkGLArea` in its place inside a `GtkOverlay`, and adds
|
||||
/// the webview back as the *overlay* child so it draws above.
|
||||
///
|
||||
/// **Must run on the GTK main thread.** Every GTK call here is main-thread-only,
|
||||
/// and the caller reaches it via `run_on_main_thread`.
|
||||
///
|
||||
/// Ordering matters: the GL area is added as the overlay's main child *before*
|
||||
/// the webview goes back, because `GtkOverlay` treats its first `add` as the
|
||||
/// bottom of the stack. Adding them the other way round yields a webview with
|
||||
/// video painted over it — an easy mistake with an obvious symptom.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231
|
||||
pub fn attach(vbox: >k::Box) -> Result<VideoSurface, SurfaceError> {
|
||||
// Tauri packs exactly one child (the webview) into the default vbox. Take it
|
||||
// rather than assume its type: wry's widget is an implementation detail, and
|
||||
// all this needs is "whatever Tauri put here".
|
||||
let children = vbox.children();
|
||||
let webview = children
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(SurfaceError::NoWebviewChild)?;
|
||||
|
||||
let gl_area = gtk::GLArea::new();
|
||||
// No depth buffer: mpv draws a flat picture into an FBO and nothing here is
|
||||
// 3D. Asking for one costs memory on every resize for nothing.
|
||||
gl_area.set_has_depth_buffer(false);
|
||||
gl_area.set_has_stencil_buffer(false);
|
||||
// Fill the overlay rather than centring at intrinsic size — the same defect
|
||||
// `videoFitClass` had to fix on the webview side, where `max-w-full` only
|
||||
// ever shrank and a 480p source rendered as a small box on a black screen.
|
||||
gl_area.set_hexpand(true);
|
||||
gl_area.set_vexpand(true);
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
|
||||
// Reparent. `remove` drops the container's reference, so hold one across the
|
||||
// move or the widget is destroyed between the two calls.
|
||||
let webview_ref = webview.clone();
|
||||
vbox.remove(&webview);
|
||||
|
||||
overlay.add(&gl_area); // main child — the bottom of the stack
|
||||
overlay.add_overlay(&webview_ref); // drawn above the video
|
||||
|
||||
// The webview must keep receiving input: it *is* the UI. `GtkOverlay` passes
|
||||
// events to overlay children by default, so pass-through stays off — setting
|
||||
// it would send clicks to the GL area, which has no controls on it.
|
||||
overlay.set_overlay_pass_through(&webview_ref, false);
|
||||
|
||||
vbox.pack_start(&overlay, true, true, 0);
|
||||
overlay.show_all();
|
||||
|
||||
info!("[VideoSurface] GL area attached beneath Tauri's webview");
|
||||
|
||||
Ok(VideoSurface { gl_area, overlay })
|
||||
}
|
||||
|
||||
/// Put Tauri's window back the way it was found.
|
||||
///
|
||||
/// Not merely tidiness: the webview outlives the video surface, so if the
|
||||
/// surface is torn down without returning the webview to the vbox the UI
|
||||
/// disappears while the app keeps running. Mirrors [`attach`] exactly.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, DR-232
|
||||
// Called by the render-context teardown, which lands with DR-232. Written now,
|
||||
// beside `attach`, because a reparent whose inverse is written later is a
|
||||
// reparent whose inverse is written wrong.
|
||||
#[allow(dead_code)]
|
||||
pub fn detach(vbox: >k::Box, surface: &VideoSurface) {
|
||||
let children = surface.overlay.children();
|
||||
for child in children {
|
||||
// Everything except the GL area came from the vbox and goes back to it.
|
||||
if child.downcast_ref::<gtk::GLArea>().is_some() {
|
||||
continue;
|
||||
}
|
||||
surface.overlay.remove(&child);
|
||||
vbox.pack_start(&child, true, true, 0);
|
||||
}
|
||||
vbox.remove(&surface.overlay);
|
||||
vbox.show_all();
|
||||
warn!("[VideoSurface] detached; webview returned to Tauri's vbox");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! These exercise GTK widget wiring, so they need a display and are ignored
|
||||
//! by default — CI has no X11 or Wayland session. Run locally with
|
||||
//! `cargo test -- --ignored video_surface`.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The stacking order is the whole point, and getting it backwards produces
|
||||
/// video painted over the controls rather than under them.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231
|
||||
#[test]
|
||||
#[ignore = "requires a display"]
|
||||
fn test_gl_area_is_below_the_reparented_webview() {
|
||||
if gtk::init().is_err() {
|
||||
return;
|
||||
}
|
||||
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
// Stand in for the webview; `attach` deliberately does not care what it is.
|
||||
let stand_in = gtk::DrawingArea::new();
|
||||
vbox.pack_start(&stand_in, true, true, 0);
|
||||
|
||||
let surface = attach(&vbox).expect("attaches");
|
||||
let children = surface.overlay().children();
|
||||
|
||||
// GtkOverlay lists its main child first.
|
||||
assert!(
|
||||
children[0].downcast_ref::<gtk::GLArea>().is_some(),
|
||||
"the GL area must be the overlay's main child, i.e. underneath"
|
||||
);
|
||||
assert!(
|
||||
children.len() > 1,
|
||||
"the reparented widget must still be present"
|
||||
);
|
||||
}
|
||||
|
||||
/// A surface that tears down without returning the webview leaves a running
|
||||
/// app with no UI.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, DR-232
|
||||
#[test]
|
||||
#[ignore = "requires a display"]
|
||||
fn test_detach_returns_the_webview_to_the_vbox() {
|
||||
if gtk::init().is_err() {
|
||||
return;
|
||||
}
|
||||
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let stand_in = gtk::DrawingArea::new();
|
||||
vbox.pack_start(&stand_in, true, true, 0);
|
||||
|
||||
let surface = attach(&vbox).expect("attaches");
|
||||
detach(&vbox, &surface);
|
||||
|
||||
let children = vbox.children();
|
||||
assert_eq!(children.len(), 1, "exactly the original child comes back");
|
||||
assert!(
|
||||
children[0].downcast_ref::<gtk::DrawingArea>().is_some(),
|
||||
"and it is the webview stand-in, not the overlay"
|
||||
);
|
||||
}
|
||||
|
||||
/// A vbox Tauri has not populated is a changed assumption, not a panic.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231
|
||||
#[test]
|
||||
#[ignore = "requires a display"]
|
||||
fn test_an_empty_vbox_is_an_error_not_a_panic() {
|
||||
if gtk::init().is_err() {
|
||||
return;
|
||||
}
|
||||
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
assert!(matches!(attach(&vbox), Err(SurfaceError::NoWebviewChild)));
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,16 @@ pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
|
||||
///
|
||||
/// TRACES: UR-004 | DR-148 | UT-142
|
||||
pub fn video_audio_codecs(detected: &str) -> String {
|
||||
// Where the video renderer decodes the audio itself (ExoPlayer), the
|
||||
// platform list *is* the answer and narrowing it to the webview's throws
|
||||
// away codecs the device genuinely plays — dts, on the tablet this was
|
||||
// found on. TRACES: UR-004, UR-080 | DR-234
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
return detected.to_string();
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
let kept: Vec<&str> = detected
|
||||
.split(',')
|
||||
.filter_map(|codec| {
|
||||
@@ -233,6 +243,83 @@ pub fn video_audio_codecs(detected: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the renderer that will actually decode video on this platform can play.
|
||||
///
|
||||
/// Returns `(video_codecs, audio_codecs)` as Jellyfin-style comma lists.
|
||||
///
|
||||
/// This exists because the answer was previously derived in four places and
|
||||
/// hardcoded in a fifth, each of them assuming the *webview* was decoding:
|
||||
/// the device profile, the transcoding targets, the direct-play audio
|
||||
/// narrowing, the client-side audio override, and `get_video_stream_url`'s
|
||||
/// `VideoCodec`. On Android the decoder is ExoPlayer, so every one of those was
|
||||
/// wrong there — the observed cost being an hevc source re-encoded to h264
|
||||
/// because its *audio* was eac3, and dts forced to transcode though the device
|
||||
/// decodes it.
|
||||
///
|
||||
/// One source, so the copies cannot disagree again.
|
||||
///
|
||||
/// TRACES: UR-004, UR-080 | DR-234
|
||||
pub fn renderer_codecs() -> (String, String) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// ExoPlayer, and the device itself answers via MediaCodecList.
|
||||
crate::player::get_detected_codecs()
|
||||
.map(|(video, audio, _channels)| (video, audio))
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!(
|
||||
"[DeviceProfile] Codec detection not complete, using conservative defaults"
|
||||
);
|
||||
("h264,hevc".to_string(), "aac,mp3".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Linux desktop draws video in the WebKitGTK HTML5 <video> element, which
|
||||
// cannot reliably decode HEVC/AV1/VP9. Claim only what it decodes, so
|
||||
// Jellyfin transcodes the rest to h264 HLS. (Audio-only playback goes
|
||||
// through MPV and is unaffected — that is a different renderer and a
|
||||
// different profile.)
|
||||
//
|
||||
// When mpv draws the picture here this stops being a platform constant and
|
||||
// becomes a question about the active renderer — which is the whole point of
|
||||
// returning it from a function rather than a `cfg` block.
|
||||
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
||||
{
|
||||
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
||||
{
|
||||
(
|
||||
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
|
||||
"aac,mp3,opus,vorbis,flac".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the renderer that decodes *video* on this platform can also decode
|
||||
/// this audio codec.
|
||||
///
|
||||
/// On a webview platform this is the webview's narrow list, because the element
|
||||
/// decodes both halves. On Android it is the device's own list: ExoPlayer plays
|
||||
/// the audio, so judging it against the webview's capabilities transcodes files
|
||||
/// that would have played.
|
||||
///
|
||||
/// TRACES: UR-004, UR-080 | DR-234
|
||||
pub fn renderer_can_decode_audio(codec: &str) -> bool {
|
||||
let codec = codec.trim();
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let (_video, audio) = renderer_codecs();
|
||||
return audio
|
||||
.split(',')
|
||||
.any(|supported| supported.trim().eq_ignore_ascii_case(codec));
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
webview_can_decode_audio(codec)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the webview `<video>` element can decode this audio codec.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
@@ -260,7 +347,9 @@ pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
||||
match served_audio_codec(streams) {
|
||||
Some(codec) => !webview_can_decode_audio(codec),
|
||||
// The renderer that will decode it, not always the webview — see
|
||||
// `renderer_can_decode_audio`. TRACES: UR-004, UR-080 | DR-234
|
||||
Some(codec) => !renderer_can_decode_audio(codec),
|
||||
// No audio at all, or a codec the server did not name: leave it alone.
|
||||
None => false,
|
||||
}
|
||||
|
||||
@@ -97,6 +97,24 @@ impl HybridRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Decide what stream to play and describe it fully — the DR-225 contract.
|
||||
///
|
||||
/// Online-only for the same reason as `get_video_stream_url`: an offline
|
||||
/// item is a file on disk, and the caller builds
|
||||
/// [`StreamSelection::local_file`] for it rather than negotiating anything.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228
|
||||
pub async fn get_stream_selection(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<super::StreamSelection, RepoError> {
|
||||
self.online
|
||||
.get_stream_selection(item_id, media_source_id, audio_stream_index)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get an audio-only stream URL for a video item (background-audio handoff).
|
||||
/// Online-only, like `get_video_stream_url`.
|
||||
///
|
||||
|
||||
@@ -5,11 +5,14 @@ pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod series_progress;
|
||||
/// Backend-owned stream selection (UR-079 / DR-225).
|
||||
pub mod stream_selection;
|
||||
pub mod types;
|
||||
|
||||
pub use hybrid::HybridRepository;
|
||||
pub use offline::OfflineRepository;
|
||||
pub use online::{JRayActor, OnlineRepository};
|
||||
pub use stream_selection::{StreamSelection, Transport};
|
||||
pub use types::*;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
+790
-255
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
//! What stream to play, decided in Rust and handed to a player whole.
|
||||
//!
|
||||
//! Every player backend — mpv, ExoPlayer, the webview `<video>`/hls.js path —
|
||||
//! used to receive a bare URL and re-derive the rest: the frontend decided
|
||||
//! "is this HLS?" by looking for `.m3u8` in the string, and nothing anywhere
|
||||
//! carried *why* a stream was transcoded or what else the source could have
|
||||
//! offered. This module is the replacement contract: one self-describing
|
||||
//! [`StreamSelection`] that says what the stream is, how to fetch it, and what
|
||||
//! the alternatives were.
|
||||
//!
|
||||
//! The division of labour it encodes — **Rust decides *what stream*, the player
|
||||
//! decides *how to deliver it*** — is the point. A multi-variant playlist handed
|
||||
//! to ExoPlayer is still ExoPlayer's to adapt over; Rust never paces bytes.
|
||||
//!
|
||||
//! TRACES: UR-079 | DR-225
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::settings::StreamingQuality;
|
||||
|
||||
/// How the bytes of a chosen stream are fetched.
|
||||
///
|
||||
/// This field exists to delete a substring search. The frontend previously
|
||||
/// decided which loader to attach by testing `url.contains(".m3u8")`, which is a
|
||||
/// domain fact reconstructed in the presentation layer — the same class of leak
|
||||
/// as the item-type taxonomy that `check:boundary` guards, and one that breaks
|
||||
/// silently the moment a server serves a playlist from a path that does not end
|
||||
/// in `.m3u8`, or serves a progressive file from one that does.
|
||||
///
|
||||
/// Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
|
||||
/// discriminant instead of comparing text.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-225
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Transport {
|
||||
/// An HLS playlist. The webview attaches hls.js (or Safari's native loader);
|
||||
/// ExoPlayer uses its HLS media source.
|
||||
Hls,
|
||||
/// A single progressive HTTP resource, seekable by byte range.
|
||||
Progressive,
|
||||
/// A file already on disk — a completed download, or the loopback media
|
||||
/// server standing in front of one.
|
||||
LocalFile,
|
||||
}
|
||||
|
||||
/// What the server is doing to the source to produce this stream.
|
||||
///
|
||||
/// Distinct from [`Transport`] because the two are genuinely independent: a
|
||||
/// direct-streamed remux and a transcode can both arrive over HLS, and a direct
|
||||
/// play can arrive progressively or as a local file. Keeping them apart is what
|
||||
/// lets the UI say "this is not costing the server anything" without inferring
|
||||
/// it from a URL shape.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-228
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PlaybackKind {
|
||||
/// The source file is served untouched. No server CPU, no quality loss.
|
||||
DirectPlay,
|
||||
/// The container is repackaged but the codecs are copied — cheap, and
|
||||
/// visually identical to the source.
|
||||
DirectStream,
|
||||
/// The server is re-encoding. The only case where a bitrate ceiling can
|
||||
/// actually be honoured, and the only one that costs the server real work.
|
||||
Transcode,
|
||||
}
|
||||
|
||||
impl PlaybackKind {
|
||||
/// Whether the server is spending encoder time on this stream.
|
||||
///
|
||||
/// The queue carries a `needs_transcoding` flag that predates this enum and
|
||||
/// that several seek/reload paths still branch on; this keeps the two from
|
||||
/// drifting by making one derive from the other.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-228
|
||||
pub fn needs_transcoding(&self) -> bool {
|
||||
matches!(self, PlaybackKind::Transcode)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendition actually negotiated — what the viewer is receiving right now.
|
||||
///
|
||||
/// `None` on a [`StreamSelection`] when the source is being direct-played as-is:
|
||||
/// there is no *chosen* rendition in that case, only the file itself, and
|
||||
/// reporting the ceiling that happened to be set would misdescribe it.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-225, DR-226
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Rendition {
|
||||
/// The rung of the ladder this stream was built against.
|
||||
pub quality: StreamingQuality,
|
||||
/// Total bits per second the stream may use, when a ceiling applies.
|
||||
pub max_bitrate: Option<u64>,
|
||||
/// Resolution ceiling, when one applies. `None` preserves the source's.
|
||||
pub max_height: Option<u32>,
|
||||
/// Video codec the server was asked to produce.
|
||||
pub video_codec: Option<String>,
|
||||
/// Audio codec the server was asked to produce.
|
||||
pub audio_codec: Option<String>,
|
||||
}
|
||||
|
||||
/// One rung of the quality picker, as it applies to *this* media source.
|
||||
///
|
||||
/// The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
|
||||
/// which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
|
||||
/// them indistinguishable from Original. `exceeds_source` is what lets the
|
||||
/// frontend render that honestly without knowing anything about bitrates.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-227, DR-121
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QualityOption {
|
||||
pub quality: StreamingQuality,
|
||||
/// Human label ("8 Mbps"). Lives in Rust beside the number it describes.
|
||||
pub label: String,
|
||||
/// Secondary line ("1080p").
|
||||
pub detail: String,
|
||||
/// True when this rung's ceiling is at or above what the source itself
|
||||
/// carries, so selecting it yields the same stream as `Original`.
|
||||
///
|
||||
/// The frontend renders these differently (or hides them); it does not
|
||||
/// decide which they are.
|
||||
pub exceeds_source: bool,
|
||||
/// The source's own bitrate, when the server reported one. Presentation
|
||||
/// only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
|
||||
pub source_bitrate: Option<u64>,
|
||||
}
|
||||
|
||||
/// Everything a player backend needs to open a stream, and everything the UI
|
||||
/// needs to describe it.
|
||||
///
|
||||
/// Replaces the bare `String` URL that `get_video_stream_url` used to return.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-225, DR-227, DR-228
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamSelection {
|
||||
/// The URL (or loopback URL) to open.
|
||||
pub url: String,
|
||||
/// How to fetch it. Replaces the `.m3u8` substring check.
|
||||
pub transport: Transport,
|
||||
/// What the server is doing to the source to produce it.
|
||||
pub playback_kind: PlaybackKind,
|
||||
/// The negotiated rendition; `None` when direct-playing the source as-is.
|
||||
pub rendition: Option<Rendition>,
|
||||
/// What this media source can offer, for the quality picker (DR-227).
|
||||
pub available: Vec<QualityOption>,
|
||||
/// The media source this selection is for, so a later re-open (quality
|
||||
/// change, audio-track switch, transcoded seek) targets the same one.
|
||||
pub media_source_id: Option<String>,
|
||||
/// The transcode identity the server keyed this job by, when there is one.
|
||||
pub play_session_id: Option<String>,
|
||||
/// Whether the server is spending encoder time on this stream.
|
||||
///
|
||||
/// Derived from [`playback_kind`](Self::playback_kind) rather than left for
|
||||
/// the frontend to compute: "which kinds count as transcoding" is a domain
|
||||
/// rule, and a direct *stream* is a remux that must not be counted. The
|
||||
/// queue's long-standing `needs_transcoding` flag and the seek strategy both
|
||||
/// read this, so there is one answer rather than three.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-225, DR-228
|
||||
pub needs_transcoding: bool,
|
||||
}
|
||||
|
||||
impl StreamSelection {
|
||||
/// A selection for a file already on disk.
|
||||
///
|
||||
/// A downloaded file is a direct play by definition — the bytes are the
|
||||
/// source's — and offering a quality ladder over it would be a lie, since
|
||||
/// nothing about a local file can be re-negotiated.
|
||||
///
|
||||
/// TRACES: UR-071, UR-079 | DR-225
|
||||
pub fn local_file(url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
url: url.into(),
|
||||
transport: Transport::LocalFile,
|
||||
playback_kind: PlaybackKind::DirectPlay,
|
||||
rendition: None,
|
||||
available: Vec::new(),
|
||||
media_source_id: None,
|
||||
play_session_id: None,
|
||||
needs_transcoding: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the quality ladder as it applies to a source of a known bitrate.
|
||||
///
|
||||
/// Every rung is returned — the picker stays a fixed, predictable list rather
|
||||
/// than one that changes length per item — but each is marked with whether it
|
||||
/// would actually constrain *this* source. A rung whose ceiling is at or above
|
||||
/// the source bitrate produces the same bytes as `Original`, so presenting it as
|
||||
/// a distinct choice is noise.
|
||||
///
|
||||
/// `source_bitrate` is `None` when the server did not report one (it is absent
|
||||
/// for some containers — the sampled library has `avi` files with no bitrate at
|
||||
/// all). In that case nothing can be judged redundant and every rung is offered,
|
||||
/// which is the safe direction: the viewer keeps every choice they had before.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
|
||||
pub fn quality_options_for_source(source_bitrate: Option<u64>) -> Vec<QualityOption> {
|
||||
StreamingQuality::ALL
|
||||
.iter()
|
||||
.map(|quality| QualityOption {
|
||||
quality: *quality,
|
||||
label: quality.label().to_string(),
|
||||
detail: quality.detail().to_string(),
|
||||
exceeds_source: match (quality.max_bitrate(), source_bitrate) {
|
||||
// `Original` is the source; it never "exceeds" it.
|
||||
(None, _) => false,
|
||||
// Nothing known about the source — judge nothing redundant.
|
||||
(Some(_), None) => false,
|
||||
(Some(cap), Some(source)) => cap >= source,
|
||||
},
|
||||
source_bitrate,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The tag the frontend matches on has to be exactly what it expects, and
|
||||
/// it is a *string in TypeScript* — nothing but a test keeps the two in step.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-225 | UT-212
|
||||
#[test]
|
||||
fn test_transport_serialises_with_the_tag_the_frontend_matches() {
|
||||
let cases = [
|
||||
(Transport::Hls, r#"{"type":"hls"}"#),
|
||||
(Transport::Progressive, r#"{"type":"progressive"}"#),
|
||||
(Transport::LocalFile, r#"{"type":"localFile"}"#),
|
||||
];
|
||||
for (transport, expected) in cases {
|
||||
let json = serde_json::to_string(&transport).expect("serialises");
|
||||
assert_eq!(json, expected, "wire shape of {transport:?}");
|
||||
let back: Transport = serde_json::from_str(&json).expect("round-trips");
|
||||
assert_eq!(back, transport);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: UR-079 | DR-228 | UT-212
|
||||
#[test]
|
||||
fn test_playback_kind_serialises_with_the_tag_the_frontend_matches() {
|
||||
let cases = [
|
||||
(PlaybackKind::DirectPlay, r#"{"type":"directPlay"}"#),
|
||||
(PlaybackKind::DirectStream, r#"{"type":"directStream"}"#),
|
||||
(PlaybackKind::Transcode, r#"{"type":"transcode"}"#),
|
||||
];
|
||||
for (kind, expected) in cases {
|
||||
let json = serde_json::to_string(&kind).expect("serialises");
|
||||
assert_eq!(json, expected, "wire shape of {kind:?}");
|
||||
let back: PlaybackKind = serde_json::from_str(&json).expect("round-trips");
|
||||
assert_eq!(back, kind);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a transcode costs the server encoder time. A direct *stream* is a
|
||||
/// remux — cheap, and not what `needs_transcoding` has ever meant.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-228 | UT-212
|
||||
#[test]
|
||||
fn test_only_transcode_counts_as_transcoding() {
|
||||
assert!(PlaybackKind::Transcode.needs_transcoding());
|
||||
assert!(!PlaybackKind::DirectStream.needs_transcoding());
|
||||
assert!(!PlaybackKind::DirectPlay.needs_transcoding());
|
||||
}
|
||||
|
||||
/// A local file is a direct play over a local transport, with no ladder:
|
||||
/// nothing about a file on disk can be re-negotiated.
|
||||
///
|
||||
/// TRACES: UR-071, UR-079 | DR-225 | UT-212
|
||||
#[test]
|
||||
fn test_local_file_selection_offers_no_ladder() {
|
||||
let selection = StreamSelection::local_file("http://127.0.0.1:9000/media/x.mkv");
|
||||
assert_eq!(selection.transport, Transport::LocalFile);
|
||||
assert_eq!(selection.playback_kind, PlaybackKind::DirectPlay);
|
||||
assert!(selection.rendition.is_none());
|
||||
assert!(selection.available.is_empty());
|
||||
assert!(!selection.needs_transcoding);
|
||||
}
|
||||
|
||||
/// The camelCase rule applies to nested struct fields too, and
|
||||
/// `playbackKind` is the one the frontend branches on.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-225 | UT-212
|
||||
#[test]
|
||||
fn test_stream_selection_fields_are_camel_case_on_the_wire() {
|
||||
let selection = StreamSelection {
|
||||
url: "https://example/master.m3u8".to_string(),
|
||||
transport: Transport::Hls,
|
||||
playback_kind: PlaybackKind::Transcode,
|
||||
rendition: Some(Rendition {
|
||||
quality: StreamingQuality::Mbps8,
|
||||
max_bitrate: Some(8_000_000),
|
||||
max_height: Some(1080),
|
||||
video_codec: Some("h264".to_string()),
|
||||
audio_codec: Some("aac".to_string()),
|
||||
}),
|
||||
available: Vec::new(),
|
||||
media_source_id: Some("src-1".to_string()),
|
||||
play_session_id: Some("sess-1".to_string()),
|
||||
needs_transcoding: true,
|
||||
};
|
||||
let json = serde_json::to_string(&selection).expect("serialises");
|
||||
assert!(
|
||||
json.contains(r#""playbackKind":{"type":"transcode"}"#),
|
||||
"{json}"
|
||||
);
|
||||
assert!(json.contains(r#""transport":{"type":"hls"}"#), "{json}");
|
||||
assert!(json.contains(r#""mediaSourceId":"src-1""#), "{json}");
|
||||
assert!(json.contains(r#""playSessionId":"sess-1""#), "{json}");
|
||||
assert!(json.contains(r#""maxBitrate":8000000"#), "{json}");
|
||||
assert!(json.contains(r#""maxHeight":1080"#), "{json}");
|
||||
assert!(json.contains(r#""needsTranscoding":true"#), "{json}");
|
||||
}
|
||||
|
||||
/// The measured library has 1.1 Mbps sources in it. Offering those a choice
|
||||
/// of 20, 10, 8, 4 and 2 Mbps is offering five ways to spell "Original".
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
|
||||
#[test]
|
||||
fn test_rungs_above_the_source_bitrate_are_marked_redundant() {
|
||||
let options = quality_options_for_source(Some(1_122_137));
|
||||
let redundant: Vec<_> = options
|
||||
.iter()
|
||||
.filter(|o| o.exceeds_source)
|
||||
.map(|o| o.quality)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
redundant,
|
||||
vec![
|
||||
StreamingQuality::Mbps20,
|
||||
StreamingQuality::Mbps10,
|
||||
StreamingQuality::Mbps8,
|
||||
StreamingQuality::Mbps4,
|
||||
StreamingQuality::Mbps2,
|
||||
],
|
||||
"every rung at or above a 1.12 Mbps source is the source"
|
||||
);
|
||||
|
||||
// The rungs that genuinely constrain it are not marked.
|
||||
let constraining: Vec<_> = options
|
||||
.iter()
|
||||
.filter(|o| !o.exceeds_source)
|
||||
.map(|o| o.quality)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
constraining,
|
||||
vec![
|
||||
StreamingQuality::Original,
|
||||
StreamingQuality::Mbps1,
|
||||
StreamingQuality::Kbps720,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `Original` is the source, so it is never "above" it — not even for a
|
||||
/// source whose bitrate is unknown or zero.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
|
||||
#[test]
|
||||
fn test_original_is_never_marked_as_exceeding_the_source() {
|
||||
for bitrate in [None, Some(0), Some(1), Some(50_000_000)] {
|
||||
let options = quality_options_for_source(bitrate);
|
||||
let original = options
|
||||
.iter()
|
||||
.find(|o| o.quality == StreamingQuality::Original)
|
||||
.expect("Original is always offered");
|
||||
assert!(!original.exceeds_source, "bitrate {bitrate:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An `avi` with no reported bitrate must not lose the picker. Judging
|
||||
/// nothing redundant is the safe direction — the viewer keeps every choice.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
|
||||
#[test]
|
||||
fn test_an_unknown_source_bitrate_keeps_every_rung_offered() {
|
||||
let options = quality_options_for_source(None);
|
||||
assert_eq!(options.len(), StreamingQuality::ALL.len());
|
||||
assert!(
|
||||
options.iter().all(|o| !o.exceeds_source),
|
||||
"nothing can be judged redundant without a source bitrate"
|
||||
);
|
||||
assert!(options.iter().all(|o| o.source_bitrate.is_none()));
|
||||
}
|
||||
|
||||
/// A 4K remux constrains at every rung — the ladder is fully meaningful.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
|
||||
#[test]
|
||||
fn test_a_source_above_the_ladder_marks_nothing_redundant() {
|
||||
let options = quality_options_for_source(Some(40_000_000));
|
||||
assert!(options.iter().all(|o| !o.exceeds_source));
|
||||
}
|
||||
|
||||
/// The picker's text comes from Rust, beside the numbers it describes, so a
|
||||
/// relabelled rung cannot drift out of step with what it does.
|
||||
///
|
||||
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
|
||||
#[test]
|
||||
fn test_options_carry_the_ladder_labels() {
|
||||
let options = quality_options_for_source(Some(6_652_961));
|
||||
assert_eq!(options.len(), StreamingQuality::ALL.len());
|
||||
for (option, quality) in options.iter().zip(StreamingQuality::ALL) {
|
||||
assert_eq!(option.quality, quality);
|
||||
assert_eq!(option.label, quality.label());
|
||||
assert_eq!(option.detail, quality.detail());
|
||||
assert_eq!(option.source_bitrate, Some(6_652_961));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,6 +469,15 @@ pub struct LiveStreamInfo {
|
||||
pub play_session_id: Option<String>,
|
||||
pub live_stream_id: Option<String>,
|
||||
pub media_source_id: Option<String>,
|
||||
/// How to open `stream_url`.
|
||||
///
|
||||
/// A live channel is always an HLS transcode — the server has to repackage a
|
||||
/// broadcast mux into something a browser can play, and there is no static
|
||||
/// file to direct-play. Saying so here means the player page never has to
|
||||
/// work it out from the URL, which is the whole of DR-225.
|
||||
///
|
||||
/// TRACES: UR-079 | DR-225
|
||||
pub transport: super::stream_selection::Transport,
|
||||
}
|
||||
|
||||
/// Genre
|
||||
|
||||
+266
-15
@@ -39,7 +39,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
|
||||
* playing there is nothing to pause, and an error would make the frontend
|
||||
* handle a case that is not a failure.
|
||||
*
|
||||
* TRACES: UR-040, UR-041 | DR-224 | UT-211
|
||||
* TRACES: UR-040, UR-041 | DR-225 | UT-212
|
||||
*/
|
||||
async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture: boolean) : Promise<BackgroundAction> {
|
||||
return await TAURI_INVOKE("player_background_action", { backgroundAudioArmed, inPictureInPicture });
|
||||
@@ -120,7 +120,7 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
|
||||
*
|
||||
* This command analyzes the current video stream and automatically chooses
|
||||
* the best seeking strategy:
|
||||
* - HLS streams (.m3u8): Use native seeking
|
||||
* - HLS streams: Use native seeking
|
||||
* - Direct play streams: Use native seeking
|
||||
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
||||
*
|
||||
@@ -263,13 +263,17 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
|
||||
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||
* native backend is reloaded here.
|
||||
*
|
||||
* The change applies to this playback *and* to everything started afterwards
|
||||
* (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||
* the in-player picker is a "this film, this connection" control, and the
|
||||
* durable default belongs to Settings. `player_set_video_settings` is the one
|
||||
* that writes to the database.
|
||||
* The change applies to **this playback only**. The in-player picker is a
|
||||
* "this film, this connection" control and its doc has always said so, but it
|
||||
* used to be implemented by writing the process-wide ceiling — so choosing
|
||||
* 2 Mbps to get one awkward film moving silently capped every video played
|
||||
* afterwards for the rest of the process, with the Settings screen still
|
||||
* showing the old value and nothing in the UI admitting the change. It now
|
||||
* sets a per-playback override that the next item clears; the durable default
|
||||
* belongs to Settings, and `player_set_video_settings` is the one that writes
|
||||
* to the database.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
* TRACES: UR-074, UR-079 | DR-162, DR-226
|
||||
*/
|
||||
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
|
||||
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
|
||||
@@ -1033,6 +1037,22 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
|
||||
async mediaLocalUrl(path: string) : Promise<string> {
|
||||
return await TAURI_INVOKE("media_local_url", { path });
|
||||
},
|
||||
/**
|
||||
* The stream selection for a downloaded file.
|
||||
*
|
||||
* The local-playback counterpart to `repository_get_stream_selection`. A file
|
||||
* on disk needs no negotiation — it is a direct play over a local transport,
|
||||
* with no quality ladder, because nothing about it can be re-negotiated — but
|
||||
* the *frontend must not be the one to say so*. It gets the same
|
||||
* [`StreamSelection`] shape as a streamed source so the player has one contract
|
||||
* to consume rather than two, and so no caller has to infer a transport from a
|
||||
* loopback URL.
|
||||
*
|
||||
* TRACES: UR-071, UR-079 | DR-225
|
||||
*/
|
||||
async mediaLocalSelection(path: string) : Promise<StreamSelection> {
|
||||
return await TAURI_INVOKE("media_local_selection", { path });
|
||||
},
|
||||
/**
|
||||
* Start downloading a file immediately
|
||||
* This command actually downloads the file using the worker
|
||||
@@ -1618,6 +1638,24 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
|
||||
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
|
||||
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
|
||||
},
|
||||
/**
|
||||
* Decide what stream to play for a video, and describe it.
|
||||
*
|
||||
* Replaces `repository_get_video_stream_url` for playback. The returned
|
||||
* [`StreamSelection`] carries the transport explicitly, so the frontend picks
|
||||
* its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
|
||||
* carries the quality ladder as it applies to *this* source, so the picker can
|
||||
* stop offering rungs that produce the same bytes as Original.
|
||||
*
|
||||
* No start-position parameter, for the same reason as the URL builder: a
|
||||
* position on an HLS playlist is copied onto every segment URI and the server
|
||||
* rejects each with `400` (DR-181). Callers resume by seeking after load.
|
||||
*
|
||||
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
|
||||
*/
|
||||
async repositoryGetStreamSelection(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamSelection> {
|
||||
return await TAURI_INVOKE("repository_get_stream_selection", { handle, itemId, mediaSourceId, audioStreamIndex });
|
||||
},
|
||||
/**
|
||||
* Get audio stream URL for a track
|
||||
*/
|
||||
@@ -1965,7 +2003,7 @@ export type AudioTrackSwitchResponse =
|
||||
/**
|
||||
* HTML5 needs to reload stream with new audio track
|
||||
*/
|
||||
{ strategy: "reloadStream"; new_url: string; position: number }
|
||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
||||
/**
|
||||
* Authentication result
|
||||
*/
|
||||
@@ -2329,7 +2367,18 @@ excludedItemIds?: string[] }
|
||||
* streamed; the server returns a transcoding URL (already absolute) plus a
|
||||
* `live_stream_id` that can later be used to close the stream.
|
||||
*/
|
||||
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
|
||||
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null;
|
||||
/**
|
||||
* How to open `stream_url`.
|
||||
*
|
||||
* A live channel is always an HLS transcode — the server has to repackage a
|
||||
* broadcast mux into something a browser can play, and there is no static
|
||||
* file to direct-play. Saying so here means the player page never has to
|
||||
* work it out from the URL, which is the whole of DR-225.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
transport: Transport }
|
||||
/**
|
||||
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
|
||||
*
|
||||
@@ -2568,6 +2617,18 @@ videoCodec: string;
|
||||
* Whether the video requires server-side transcoding
|
||||
*/
|
||||
needsTranscoding: boolean;
|
||||
/**
|
||||
* How this item's stream is fetched, as the backend decided it.
|
||||
*
|
||||
* Carried on the queue item so a later seek/reload does not have to guess.
|
||||
* `None` for items queued by a path that never negotiated (audio tracks,
|
||||
* direct URLs) and for anything queued before this field existed, where the
|
||||
* caller falls back to `needs_transcoding` — every transcode this app
|
||||
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
||||
*
|
||||
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
|
||||
*/
|
||||
transport?: Transport | null;
|
||||
/**
|
||||
* Optional now-playing metadata. Used by the background-audio handoff so the
|
||||
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
||||
@@ -2680,6 +2741,32 @@ supportsNativeVideo: boolean }
|
||||
* Playback information
|
||||
*/
|
||||
export type PlaybackInfo = { mediaSourceId: string; playSessionId: string; streamUrl: string; directPlay: boolean; needsTranscoding: boolean }
|
||||
/**
|
||||
* What the server is doing to the source to produce this stream.
|
||||
*
|
||||
* Distinct from [`Transport`] because the two are genuinely independent: a
|
||||
* direct-streamed remux and a transcode can both arrive over HLS, and a direct
|
||||
* play can arrive progressively or as a local file. Keeping them apart is what
|
||||
* lets the UI say "this is not costing the server anything" without inferring
|
||||
* it from a URL shape.
|
||||
*
|
||||
* TRACES: UR-079 | DR-228
|
||||
*/
|
||||
export type PlaybackKind =
|
||||
/**
|
||||
* The source file is served untouched. No server CPU, no quality loss.
|
||||
*/
|
||||
{ type: "directPlay" } |
|
||||
/**
|
||||
* The container is repackaged but the codecs are copied — cheap, and
|
||||
* visually identical to the source.
|
||||
*/
|
||||
{ type: "directStream" } |
|
||||
/**
|
||||
* The server is re-encoding. The only case where a bitrate ceiling can
|
||||
* actually be honoured, and the only one that costs the server real work.
|
||||
*/
|
||||
{ type: "transcode" }
|
||||
/**
|
||||
* Playback mode - local device, remote session, or idle
|
||||
*/
|
||||
@@ -2779,6 +2866,18 @@ videoCodec?: string | null;
|
||||
* Whether the video requires server-side transcoding
|
||||
*/
|
||||
needsTranscoding?: boolean;
|
||||
/**
|
||||
* How this item's stream is fetched, as the backend decided it.
|
||||
*
|
||||
* Carried on the queue item so a later seek/reload does not have to guess.
|
||||
* `None` for items queued by a path that never negotiated (audio tracks,
|
||||
* direct URLs) and for anything queued before this field existed, where the
|
||||
* caller falls back to `needs_transcoding` — every transcode this app
|
||||
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
|
||||
*
|
||||
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
|
||||
*/
|
||||
transport?: Transport | null;
|
||||
/**
|
||||
* Video width in pixels
|
||||
*/
|
||||
@@ -3061,6 +3160,38 @@ alreadyDownloaded: number;
|
||||
* Number of tracks skipped (no jellyfin ID or other reasons)
|
||||
*/
|
||||
skipped: number }
|
||||
/**
|
||||
* One rung of the quality picker, as it applies to *this* media source.
|
||||
*
|
||||
* The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
|
||||
* which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
|
||||
* them indistinguishable from Original. `exceeds_source` is what lets the
|
||||
* frontend render that honestly without knowing anything about bitrates.
|
||||
*
|
||||
* TRACES: UR-070, UR-079 | DR-227, DR-121
|
||||
*/
|
||||
export type QualityOption = { quality: StreamingQuality;
|
||||
/**
|
||||
* Human label ("8 Mbps"). Lives in Rust beside the number it describes.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Secondary line ("1080p").
|
||||
*/
|
||||
detail: string;
|
||||
/**
|
||||
* True when this rung's ceiling is at or above what the source itself
|
||||
* carries, so selecting it yields the same stream as `Original`.
|
||||
*
|
||||
* The frontend renders these differently (or hides them); it does not
|
||||
* decide which they are.
|
||||
*/
|
||||
exceedsSource: boolean;
|
||||
/**
|
||||
* The source's own bitrate, when the server reported one. Presentation
|
||||
* only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
|
||||
*/
|
||||
sourceBitrate: number | null }
|
||||
/**
|
||||
* Response for queue queries
|
||||
*/
|
||||
@@ -3069,6 +3200,36 @@ export type QueueStatus = { items: PlayerMediaItem[]; currentIndex: number | nul
|
||||
* Remote session status for UI updates
|
||||
*/
|
||||
export type RemoteSessionStatus = { position: number; duration: number | null; isPlaying: boolean; nowPlayingItem: NowPlayingItem | null }
|
||||
/**
|
||||
* The rendition actually negotiated — what the viewer is receiving right now.
|
||||
*
|
||||
* `None` on a [`StreamSelection`] when the source is being direct-played as-is:
|
||||
* there is no *chosen* rendition in that case, only the file itself, and
|
||||
* reporting the ceiling that happened to be set would misdescribe it.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225, DR-226
|
||||
*/
|
||||
export type Rendition = {
|
||||
/**
|
||||
* The rung of the ladder this stream was built against.
|
||||
*/
|
||||
quality: StreamingQuality;
|
||||
/**
|
||||
* Total bits per second the stream may use, when a ceiling applies.
|
||||
*/
|
||||
maxBitrate: number | null;
|
||||
/**
|
||||
* Resolution ceiling, when one applies. `None` preserves the source's.
|
||||
*/
|
||||
maxHeight: number | null;
|
||||
/**
|
||||
* Video codec the server was asked to produce.
|
||||
*/
|
||||
videoCodec: string | null;
|
||||
/**
|
||||
* Audio codec the server was asked to produce.
|
||||
*/
|
||||
audioCodec: string | null }
|
||||
/**
|
||||
* Repeat mode for the queue
|
||||
*
|
||||
@@ -3182,13 +3343,73 @@ export type StreamKind = "audio" | "video" | "subtitle" |
|
||||
*/
|
||||
export type StreamQualityResponse =
|
||||
/**
|
||||
* The native backend was reloaded here; nothing left for the frontend.
|
||||
* The native backend was reloaded here; nothing left for the frontend to
|
||||
* *do* — but it still has to be told what was negotiated.
|
||||
*
|
||||
* This carried only a position at first, which left the picker on Android
|
||||
* pinned to the rendition of the *first* stream: the UI derives the rung in
|
||||
* force from the selection it holds, nothing replaced that selection on the
|
||||
* native path, and a transcode always has a rendition — so the fallback
|
||||
* that would have used the requested value was never reached. The stream
|
||||
* changed and the menu did not.
|
||||
*
|
||||
* TRACES: UR-074, UR-079 | DR-226, DR-227
|
||||
*/
|
||||
{ strategy: "native"; position: number } |
|
||||
{ strategy: "native"; selection: StreamSelection; position: number } |
|
||||
/**
|
||||
* HTML5 must reload its element with this URL.
|
||||
* HTML5 must reload its element with this selection.
|
||||
*/
|
||||
{ strategy: "reloadStream"; new_url: string; position: number }
|
||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
||||
/**
|
||||
* Everything a player backend needs to open a stream, and everything the UI
|
||||
* needs to describe it.
|
||||
*
|
||||
* Replaces the bare `String` URL that `get_video_stream_url` used to return.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225, DR-227, DR-228
|
||||
*/
|
||||
export type StreamSelection = {
|
||||
/**
|
||||
* The URL (or loopback URL) to open.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* How to fetch it. Replaces the `.m3u8` substring check.
|
||||
*/
|
||||
transport: Transport;
|
||||
/**
|
||||
* What the server is doing to the source to produce it.
|
||||
*/
|
||||
playbackKind: PlaybackKind;
|
||||
/**
|
||||
* The negotiated rendition; `None` when direct-playing the source as-is.
|
||||
*/
|
||||
rendition: Rendition | null;
|
||||
/**
|
||||
* What this media source can offer, for the quality picker (DR-227).
|
||||
*/
|
||||
available: QualityOption[];
|
||||
/**
|
||||
* The media source this selection is for, so a later re-open (quality
|
||||
* change, audio-track switch, transcoded seek) targets the same one.
|
||||
*/
|
||||
mediaSourceId: string | null;
|
||||
/**
|
||||
* The transcode identity the server keyed this job by, when there is one.
|
||||
*/
|
||||
playSessionId: string | null;
|
||||
/**
|
||||
* Whether the server is spending encoder time on this stream.
|
||||
*
|
||||
* Derived from [`playback_kind`](Self::playback_kind) rather than left for
|
||||
* the frontend to compute: "which kinds count as transcoding" is a domain
|
||||
* rule, and a direct *stream* is a remux that must not be counted. The
|
||||
* queue's long-standing `needs_transcoding` flag and the seek strategy both
|
||||
* read this, so there is one answer rather than three.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225, DR-228
|
||||
*/
|
||||
needsTranscoding: boolean }
|
||||
/**
|
||||
* A ceiling on how much bandwidth a *video* stream may consume.
|
||||
*
|
||||
@@ -3269,6 +3490,36 @@ itemName: string | null }
|
||||
* Statistics about the thumbnail cache
|
||||
*/
|
||||
export type ThumbnailCacheStats = { totalSizeBytes: number; itemCount: number; limitBytes: number }
|
||||
/**
|
||||
* How the bytes of a chosen stream are fetched.
|
||||
*
|
||||
* This field exists to delete a substring search. The frontend previously
|
||||
* decided which loader to attach by testing `url.contains(".m3u8")`, which is a
|
||||
* domain fact reconstructed in the presentation layer — the same class of leak
|
||||
* as the item-type taxonomy that `check:boundary` guards, and one that breaks
|
||||
* silently the moment a server serves a playlist from a path that does not end
|
||||
* in `.m3u8`, or serves a progressive file from one that does.
|
||||
*
|
||||
* Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
|
||||
* discriminant instead of comparing text.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
export type Transport =
|
||||
/**
|
||||
* An HLS playlist. The webview attaches hls.js (or Safari's native loader);
|
||||
* ExoPlayer uses its HLS media source.
|
||||
*/
|
||||
{ type: "hls" } |
|
||||
/**
|
||||
* A single progressive HTTP resource, seekable by byte range.
|
||||
*/
|
||||
{ type: "progressive" } |
|
||||
/**
|
||||
* A file already on disk — a completed download, or the loopback media
|
||||
* server standing in front of one.
|
||||
*/
|
||||
{ type: "localFile" }
|
||||
/**
|
||||
* User information
|
||||
*/
|
||||
@@ -3316,7 +3567,7 @@ export type VideoSeekResponse =
|
||||
/**
|
||||
* Reload stream from new position (transcoded non-HLS)
|
||||
*/
|
||||
{ strategy: "reloadStream"; new_url: string; seek_offset: number }
|
||||
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
|
||||
/**
|
||||
* Video playback settings
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// NO direct HTTP calls - everything routes through Rust backend
|
||||
|
||||
import { commands } from "./bindings";
|
||||
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings";
|
||||
import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
|
||||
import type { QualityPreset } from "./quality-presets";
|
||||
import type {
|
||||
Library,
|
||||
@@ -247,6 +247,34 @@ export class RepositoryClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what stream to play, and describe it.
|
||||
*
|
||||
* The playback counterpart to {@link getVideoStreamUrl}, which returns only a
|
||||
* URL and therefore forces its caller to work out the rest. This returns the
|
||||
* transport (so the player picks a loader from a tagged enum rather than by
|
||||
* searching the URL for `.m3u8`), the playback kind (direct play / direct
|
||||
* stream / transcode), and the quality ladder as it applies to this source.
|
||||
*
|
||||
* No position parameter, for the same reason as {@link getVideoStreamUrl}: a
|
||||
* start position on an HLS playlist makes Jellyfin reject every segment behind
|
||||
* it with `400` (DR-181). Resume by seeking once loaded.
|
||||
*
|
||||
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
|
||||
*/
|
||||
async getStreamSelection(
|
||||
itemId: string,
|
||||
mediaSourceId?: string | null,
|
||||
audioStreamIndex?: number | null,
|
||||
): Promise<StreamSelection> {
|
||||
return commands.repositoryGetStreamSelection(
|
||||
this.ensureHandle(),
|
||||
itemId,
|
||||
mediaSourceId ?? null,
|
||||
audioStreamIndex ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio-only stream URL for a video item, for the background-audio handoff.
|
||||
* The server extracts just the audio track — no video is decoded on-device.
|
||||
|
||||
@@ -123,6 +123,23 @@ import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import { player } from "$lib/stores/player";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
function makeEpisode(): MediaItem {
|
||||
return {
|
||||
id: "ep1",
|
||||
@@ -136,7 +153,7 @@ async function mountNativePlayer() {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
||||
mediaSourceId: "src-1",
|
||||
needsTranscoding: false,
|
||||
onClose: vi.fn(),
|
||||
@@ -261,7 +278,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
||||
mediaSourceId: "src-1",
|
||||
needsTranscoding: false,
|
||||
onClose: vi.fn(),
|
||||
@@ -303,7 +320,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
||||
mediaSourceId: "src-1",
|
||||
needsTranscoding: false,
|
||||
onClose: vi.fn(),
|
||||
|
||||
@@ -118,6 +118,23 @@ import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
function makeEpisode(): MediaItem {
|
||||
return {
|
||||
id: "ep1",
|
||||
@@ -139,7 +156,7 @@ async function mountAndroidPlayer() {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
||||
mediaSourceId: "src-1",
|
||||
needsTranscoding: false,
|
||||
onClose: vi.fn(),
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { JRayActor, StreamingQuality, BackgroundAction } from "$lib/api/bindings";
|
||||
import type {
|
||||
JRayActor,
|
||||
StreamingQuality,
|
||||
BackgroundAction,
|
||||
StreamSelection,
|
||||
} from "$lib/api/bindings";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Hls from "hls.js";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
@@ -77,12 +82,21 @@
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
|
||||
|
||||
const log = createLogger("VideoPlayer");
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
streamUrl: string;
|
||||
/**
|
||||
* What to play, as the backend decided it: URL, transport, playback kind and
|
||||
* the quality ladder for this source. Replaces the bare `streamUrl` string,
|
||||
* which forced this component to re-derive the transport by searching for
|
||||
* `.m3u8`.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225, DR-227
|
||||
*/
|
||||
selection: StreamSelection;
|
||||
mediaSourceId?: string; // Media source ID for subtitle URLs
|
||||
initialPosition?: number; // Position in seconds to seek to after load (for resume)
|
||||
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
|
||||
@@ -103,7 +117,7 @@
|
||||
|
||||
let {
|
||||
media,
|
||||
streamUrl,
|
||||
selection,
|
||||
mediaSourceId,
|
||||
initialPosition,
|
||||
needsTranscoding = false,
|
||||
@@ -179,7 +193,18 @@
|
||||
// Capture only the initial streamUrl prop; later prop changes are applied via
|
||||
// the $effect below (untrack keeps this a one-time snapshot, matching
|
||||
// reportMediaId above and silencing state_referenced_locally).
|
||||
let currentStreamUrl = $state(untrack(() => streamUrl));
|
||||
// The selection currently loaded. Starts from the prop and is replaced
|
||||
// wholesale by a reload (quality change, audio-track switch, transcoded seek)
|
||||
// so transport and URL can never disagree.
|
||||
// TRACES: UR-079 | DR-225
|
||||
let currentSelection = $state<StreamSelection>(untrack(() => selection));
|
||||
const currentStreamUrl = $derived(currentSelection.url);
|
||||
/**
|
||||
* The transport as a plain string, so effects can depend on its *value*.
|
||||
* A `$derived` primitive only notifies when it actually changes, which is what
|
||||
* keeps the HLS teardown from re-running for an unchanged stream.
|
||||
*/
|
||||
const transportKind = $derived(currentSelection.transport.type);
|
||||
let hasReportedStart = $state(false);
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
|
||||
@@ -250,14 +275,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A selection identical to the one loaded, but pointing at a different URL.
|
||||
*
|
||||
* Used by the paths that swap the stream without re-negotiating — the
|
||||
* background-audio handoff and its return. Each states the transport it is
|
||||
* moving to rather than letting it be inferred, which is the whole point of
|
||||
* DR-225: the audio handoff really is a progressive mp3, and the rebuilt
|
||||
* video stream really is an HLS transcode, and neither is knowable from the
|
||||
* URL text.
|
||||
*
|
||||
* TRACES: UR-040, UR-079 | DR-225
|
||||
*/
|
||||
function selectionAt(url: string, transport: StreamSelection["transport"]): StreamSelection {
|
||||
// A re-opened stream is a new transcode job; the old session id is stale.
|
||||
return { ...currentSelection, url, transport, playSessionId: null };
|
||||
}
|
||||
|
||||
const adapterBridge: Html5ElementBridge = {
|
||||
getElement: () => videoElement,
|
||||
getSeekOffset: () => seekOffset,
|
||||
setSeekOffset: (o) => {
|
||||
seekOffset = o;
|
||||
},
|
||||
setStreamUrl: (u) => {
|
||||
currentStreamUrl = u;
|
||||
setStreamSelection: (sel) => {
|
||||
currentSelection = sel;
|
||||
},
|
||||
destroyHls: tearDownHls,
|
||||
getMediaSourceId: () => mediaSourceId ?? null,
|
||||
@@ -275,9 +317,46 @@
|
||||
// Rust — the frontend never encodes what a step means.
|
||||
// TRACES: UR-074 | DR-162
|
||||
let showQualityMenu = $state(false);
|
||||
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
|
||||
let selectedQuality = $state<StreamingQuality>("original");
|
||||
let changingQuality = $state(false);
|
||||
/**
|
||||
* The device's durable default, shown when the stream is a direct play and so
|
||||
* has no rendition of its own to report. Read once from Settings.
|
||||
*/
|
||||
let defaultQuality = $state<StreamingQuality>("original");
|
||||
|
||||
/**
|
||||
* The rungs to offer for the stream that is playing, straight from the
|
||||
* backend (DR-227). Rungs whose ceiling is at or above the source bitrate are
|
||||
* dropped: they produce the same bytes as Original, so listing five of them is
|
||||
* five ways to spell one choice. Rust decides which those are — this only
|
||||
* decides not to draw them.
|
||||
*
|
||||
* `Original` is always kept; it is the source, never redundant with it.
|
||||
*
|
||||
* TRACES: UR-070, UR-079 | DR-227, DR-121
|
||||
*/
|
||||
const qualityOptions = $derived(
|
||||
currentSelection.available.filter((o) => !o.exceedsSource || o.quality === "original"),
|
||||
);
|
||||
|
||||
/**
|
||||
* The rung in force. A transcode reports the rendition it was built against;
|
||||
* a direct play has none, because it *is* the source — so it reads as
|
||||
* Original rather than as whatever ceiling happens to be set.
|
||||
*/
|
||||
const selectedQuality = $derived<StreamingQuality>(
|
||||
currentSelection.rendition?.quality ??
|
||||
(currentSelection.playbackKind.type === "transcode" ? defaultQuality : "original"),
|
||||
);
|
||||
|
||||
/** Human line for what the server is doing with this stream. */
|
||||
const playbackKindLabel = $derived(
|
||||
currentSelection.playbackKind.type === "directPlay"
|
||||
? "Direct play — the original file"
|
||||
: currentSelection.playbackKind.type === "directStream"
|
||||
? "Direct stream — repackaged, not re-encoded"
|
||||
: "Transcoding on the server",
|
||||
);
|
||||
|
||||
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
||||
let videoDuration = $state(0);
|
||||
@@ -450,9 +529,9 @@
|
||||
// Update stream URL when prop changes (from parent component, not from internal seeks)
|
||||
$effect(() => {
|
||||
// Only reset when the streamUrl prop actually changes from parent
|
||||
if (streamUrl !== lastStreamUrlProp) {
|
||||
lastStreamUrlProp = streamUrl;
|
||||
currentStreamUrl = streamUrl;
|
||||
if (selection.url !== lastStreamUrlProp) {
|
||||
lastStreamUrlProp = selection.url;
|
||||
currentSelection = selection;
|
||||
seekOffset = 0;
|
||||
isMediaReady = false; // Reset to loading state when stream URL changes
|
||||
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
||||
@@ -567,9 +646,21 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
// The loader comes from the backend's tagged transport, never from the URL.
|
||||
//
|
||||
// Read through the *primitive* `transportKind`, never `currentSelection`
|
||||
// itself: this effect tears down and rebuilds hls.js, and a selection object
|
||||
// is replaced on every reload — so depending on the object re-ran the whole
|
||||
// teardown for an unchanged stream and left the element showing nothing
|
||||
// until a seek forced another cycle.
|
||||
//
|
||||
// TRACES: UR-079 | DR-225 | UT-214
|
||||
const loader = loaderForTransport(transportKind, {
|
||||
hlsJsSupported: Hls.isSupported(),
|
||||
nativeHlsSupported: !!videoElement.canPlayType("application/vnd.apple.mpegurl"),
|
||||
});
|
||||
|
||||
if (isHlsStream && Hls.isSupported()) {
|
||||
if (loader === "hlsjs") {
|
||||
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
||||
if (hls) {
|
||||
log.debug("Cleaning up existing HLS instance");
|
||||
@@ -724,13 +815,13 @@
|
||||
videoElement.pause();
|
||||
}
|
||||
};
|
||||
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Native HLS support (Safari)
|
||||
} else if (loader === "nativeHls") {
|
||||
// The element parses the playlist itself (Safari/WebKit).
|
||||
log.debug("Using native HLS support");
|
||||
videoElement.src = currentStreamUrl;
|
||||
} else {
|
||||
// Not an HLS stream, use regular video element
|
||||
log.debug("Using regular video element for non-HLS stream");
|
||||
// Progressive or local: the element loads the URL directly.
|
||||
log.debug("Using regular video element", currentSelection.transport.type);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -801,21 +892,25 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Populate the quality menu. Deliberately its own *synchronous* onMount that
|
||||
// fires the load without awaiting it: an await inside the main onMount below
|
||||
// flips the component into HTML5 mode and breaks native seeking, and nothing
|
||||
// about playback waits on this list.
|
||||
// The quality *ladder* now arrives with the stream selection (DR-227), so all
|
||||
// this still needs is the device default, for the case where the stream is a
|
||||
// direct play and has no rendition of its own.
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
// Deliberately its own *synchronous* onMount that fires the load without
|
||||
// awaiting it: an await inside the main onMount below flips the component into
|
||||
// HTML5 mode and breaks native seeking, and nothing about playback waits on
|
||||
// this value.
|
||||
//
|
||||
// TRACES: UR-074, UR-079 | DR-162, DR-227
|
||||
onMount(() => {
|
||||
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
|
||||
.then(([qualities, settings]) => {
|
||||
streamingQualities = qualities;
|
||||
commands
|
||||
.playerGetVideoSettings()
|
||||
.then((settings) => {
|
||||
// Optional on the wire (serde default) — absent means uncapped.
|
||||
selectedQuality = settings.streamingQuality ?? "original";
|
||||
defaultQuality = settings.streamingQuality ?? "original";
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn("Failed to load streaming qualities:", err);
|
||||
log.warn("Failed to load the default streaming quality:", err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -878,6 +973,9 @@
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding: needsTranscoding,
|
||||
// Carry the negotiated transport onto the queue item so a later seek
|
||||
// reads it instead of falling back. TRACES: UR-079 | DR-230
|
||||
transport: currentSelection.transport,
|
||||
// Order matters: player_set_subtitle_track(n) is a position in this
|
||||
// array. Previously this array was built and then dropped, so
|
||||
// ExoPlayer got a MediaItem with no subtitles at all.
|
||||
@@ -944,7 +1042,9 @@
|
||||
const host = createRustReportHost(media.id, {
|
||||
onEnded: () => notifyEnded(),
|
||||
onStreamUrlChanged: (u) => {
|
||||
currentStreamUrl = u;
|
||||
// Rust re-opened the same stream (a transcoded seek): the
|
||||
// transport is unchanged, only the job behind it.
|
||||
currentSelection = selectionAt(u, currentSelection.transport);
|
||||
},
|
||||
});
|
||||
playerAdapter = createAdapter({
|
||||
@@ -1746,7 +1846,7 @@
|
||||
// whether the item has a picture to lose, which is Rust's to know. This used
|
||||
// to be decided implicitly by Kotlin gating the event on the toggle, which
|
||||
// is why the native path -- whose media service keeps playing regardless --
|
||||
// ignored the toggle entirely (DR-224).
|
||||
// ignored the toggle entirely (DR-225).
|
||||
let action: BackgroundAction;
|
||||
try {
|
||||
action = await commands.playerBackgroundAction(
|
||||
@@ -1885,8 +1985,9 @@
|
||||
|
||||
pendingForegroundPlay = plan.shouldPlay;
|
||||
|
||||
// Determine the target URL + how the element/offset should be positioned.
|
||||
let targetUrl: string;
|
||||
// Determine the target stream + how the element/offset should be
|
||||
// positioned.
|
||||
let targetSelection: StreamSelection;
|
||||
if (needsTranscoding && onSeek) {
|
||||
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
|
||||
// stream starts at the BEGINNING of the item, not at `pos`: a start
|
||||
@@ -1897,13 +1998,16 @@
|
||||
// that really did start there; leaving it would now display `pos` while
|
||||
// playing the opening titles.
|
||||
// TRACES: UR-040, UR-004 | DR-181
|
||||
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
|
||||
// Every transcode this app requests is HLS (DR-140).
|
||||
targetSelection = selectionAt(await onSeek(pos, selectedAudioTrackIndex ?? undefined), {
|
||||
type: "hls",
|
||||
});
|
||||
seekOffset = 0;
|
||||
currentTime = pos;
|
||||
pendingForegroundSeek = pos;
|
||||
} else {
|
||||
// Direct stream: reload the original URL and seek the element to pos.
|
||||
targetUrl = streamUrl;
|
||||
// Direct stream: reload the original selection and seek to pos.
|
||||
targetSelection = selection;
|
||||
seekOffset = 0;
|
||||
pendingForegroundSeek = pos;
|
||||
}
|
||||
@@ -1923,18 +2027,21 @@
|
||||
// than re-fetched.
|
||||
//
|
||||
// TRACES: UR-040, UR-003 | DR-196
|
||||
currentStreamUrl = targetUrl;
|
||||
currentSelection = targetSelection;
|
||||
await commands.playerPlayItem({
|
||||
streamUrl: targetUrl,
|
||||
streamUrl: targetSelection.url,
|
||||
title: media.name,
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding,
|
||||
// TRACES: UR-079 | DR-230
|
||||
transport: targetSelection.transport,
|
||||
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||
});
|
||||
didStartNativePlayback = true;
|
||||
await playerAdapter?.load(targetUrl, {
|
||||
await playerAdapter?.load(targetSelection.url, {
|
||||
mediaId: media.id,
|
||||
selection: targetSelection,
|
||||
mediaSourceId: mediaSourceId ?? null,
|
||||
needsTranscoding,
|
||||
initialPosition: plan.position,
|
||||
@@ -1961,9 +2068,9 @@
|
||||
// blank it first, then set it on the next microtask so Svelte sees a real
|
||||
// transition. Without this, assigning the same value is a no-op and the
|
||||
// player stays stuck on the loading spinner (HLS never re-initialises).
|
||||
currentStreamUrl = "";
|
||||
currentSelection = selectionAt("", targetSelection.transport);
|
||||
await Promise.resolve();
|
||||
currentStreamUrl = targetUrl;
|
||||
currentSelection = targetSelection;
|
||||
} catch (err) {
|
||||
log.error("Background-audio return failed:", err);
|
||||
}
|
||||
@@ -2285,33 +2392,48 @@
|
||||
*
|
||||
* The backend owns everything about how that happens — it decides whether the
|
||||
* caller reloads (HTML5) or it reloads the native backend itself — so this
|
||||
* only supplies the position to resume at and reverts the selection if the
|
||||
* switch fails.
|
||||
* only supplies the position to resume at.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
* The change applies to this playback alone; the durable Settings default is
|
||||
* untouched (DR-226). Nothing is optimistically assigned here: what the picker
|
||||
* shows comes from the selection the backend hands back, because what you get
|
||||
* is not always what you asked for — a ceiling above the source bitrate is the
|
||||
* source, and claiming otherwise is the kind of lie the old picker told.
|
||||
*
|
||||
* TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
|
||||
*/
|
||||
async function selectQuality(quality: StreamingQuality) {
|
||||
showQualityMenu = false;
|
||||
if (quality === selectedQuality || changingQuality) return;
|
||||
|
||||
const previous = selectedQuality;
|
||||
selectedQuality = quality;
|
||||
changingQuality = true;
|
||||
try {
|
||||
stopTimeUpdates();
|
||||
await playerController.setStreamQuality(
|
||||
const negotiated = await playerController.setStreamQuality(
|
||||
quality,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex,
|
||||
);
|
||||
// Adopt whatever the backend says it opened. The HTML5 path has already
|
||||
// set this via the adapter bridge, so this is a no-op there; the native
|
||||
// path reloads inside Rust and this is the only thing that updates the UI.
|
||||
//
|
||||
// Assigning it is what keeps the picker honest: `selectedQuality` reads
|
||||
// the selection's rendition, and a transcode always has one — so without
|
||||
// this the menu stayed on the first stream's rung while the stream itself
|
||||
// changed underneath.
|
||||
//
|
||||
// TRACES: UR-074, UR-079 | DR-226, DR-227
|
||||
if (negotiated) {
|
||||
currentSelection = negotiated;
|
||||
}
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
}
|
||||
log.debug("Streaming quality changed:", quality);
|
||||
} catch (err) {
|
||||
log.error("Failed to change streaming quality:", err);
|
||||
selectedQuality = previous;
|
||||
} finally {
|
||||
changingQuality = false;
|
||||
}
|
||||
@@ -2411,12 +2533,30 @@
|
||||
aria-label="Video player"
|
||||
>
|
||||
<!-- Video -->
|
||||
<div class="flex-1 flex items-center justify-center relative">
|
||||
<!--
|
||||
`min-h-0` / `min-w-0` are load-bearing, not defensive. A flex item defaults
|
||||
to `min-height: auto`, which refuses to shrink below its content's intrinsic
|
||||
size — and the <video> inside reports the *media's* natural dimensions. So
|
||||
without them this wrapper grows past the viewport whenever the picture is
|
||||
larger than the window: the overflow goes off the bottom, which reads as the
|
||||
image being cropped and aligned to the top rather than letterboxed and
|
||||
centred. `object-contain` was never the problem; it was doing its job inside
|
||||
a box that was itself the wrong size.
|
||||
|
||||
Reproduces by resizing the window during playback, and by entering
|
||||
fullscreen — where the same overflow put the picture at the bottom.
|
||||
|
||||
TRACES: UR-005 | DR-024
|
||||
-->
|
||||
<div class="flex-1 min-h-0 min-w-0 flex items-center justify-center relative">
|
||||
{#if !!useHtml5Element}
|
||||
<!-- HTML5 video for desktop/non-Android platforms -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
|
||||
src={elementSrcFor(currentSelection, {
|
||||
hlsJsSupported: Hls.isSupported(),
|
||||
nativeHlsSupported: true,
|
||||
})}
|
||||
crossorigin={videoCrossOrigin}
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
@@ -2756,8 +2896,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
|
||||
{#if streamingQualities.length > 0}
|
||||
<!--
|
||||
Streaming quality (bandwidth ceiling), populated from what this media
|
||||
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
|
||||
-->
|
||||
{#if qualityOptions.length > 1}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleQualityMenu}
|
||||
@@ -2778,22 +2921,30 @@
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Quality
|
||||
<div class="px-3 py-2 border-b border-white/20">
|
||||
<div class="text-white text-sm font-semibold">Quality</div>
|
||||
<!--
|
||||
What the server is actually doing. Only knowable now that
|
||||
the backend reports it. TRACES: UR-079 | DR-228
|
||||
-->
|
||||
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
||||
</div>
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
{#each qualityOptions as option (option.quality)}
|
||||
<button
|
||||
onclick={() => selectQuality(quality)}
|
||||
onclick={() => selectQuality(option.quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
quality
|
||||
option.quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{label}</span>
|
||||
<span class="text-xs text-gray-400">{detail}</span>
|
||||
<span class="text-sm">{option.label}</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
||||
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if selectedQuality === quality}
|
||||
{#if selectedQuality === option.quality}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
|
||||
@@ -36,6 +36,23 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
|
||||
|
||||
const toggleSpy = vi.fn();
|
||||
@@ -122,7 +139,7 @@ function touchAt(el: Element, x: number) {
|
||||
|
||||
function renderPlayer() {
|
||||
return render(VideoPlayer, {
|
||||
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
|
||||
props: { media: MEDIA, selection: testSelection("http://x/master.m3u8"), onClose: vi.fn() },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,23 @@ import { tick } from "svelte";
|
||||
import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
function makeEpisode(): MediaItem {
|
||||
return {
|
||||
id: "ep1",
|
||||
@@ -129,7 +146,7 @@ async function mountAndroidPlayer() {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
||||
mediaSourceId: "src-1",
|
||||
needsTranscoding: false,
|
||||
onClose: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { videoFitClass, fittedVideoSize } from "./videoFit";
|
||||
import { videoFitClass } from "./videoFit";
|
||||
|
||||
describe("videoFitClass", () => {
|
||||
it("fills the container instead of capping at the source's intrinsic size", () => {
|
||||
@@ -19,39 +19,3 @@ describe("videoFitClass", () => {
|
||||
expect(cls).not.toContain("object-fill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fittedVideoSize", () => {
|
||||
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
|
||||
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
|
||||
// staying a 854x480 box in the middle.
|
||||
const size = fittedVideoSize(853.33, 480, 1920, 1080);
|
||||
expect(size.width).toBeCloseTo(1920, 0);
|
||||
expect(size.height).toBeCloseTo(1080, 0);
|
||||
});
|
||||
|
||||
it("fits to the constraining dimension when aspect ratios differ", () => {
|
||||
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
|
||||
const size = fittedVideoSize(640, 480, 1920, 1080);
|
||||
expect(size.height).toBeCloseTo(1080, 0);
|
||||
expect(size.width).toBeCloseTo(1440, 0);
|
||||
expect(size.width).toBeLessThan(1920);
|
||||
});
|
||||
|
||||
it("fits to width when the source is wider than the window", () => {
|
||||
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
|
||||
const size = fittedVideoSize(2560, 1080, 1920, 1080);
|
||||
expect(size.width).toBeCloseTo(1920, 0);
|
||||
expect(size.height).toBeCloseTo(810, 0);
|
||||
expect(size.height).toBeLessThan(1080);
|
||||
});
|
||||
|
||||
it("shrinks oversized media to fit rather than overflowing", () => {
|
||||
const size = fittedVideoSize(3840, 2160, 1280, 720);
|
||||
expect(size.width).toBeCloseTo(1280, 0);
|
||||
expect(size.height).toBeCloseTo(720, 0);
|
||||
});
|
||||
|
||||
it("returns a zero size for unknown intrinsic dimensions", () => {
|
||||
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,32 +15,3 @@
|
||||
export function videoFitClass(): string {
|
||||
return "w-full h-full object-contain";
|
||||
}
|
||||
|
||||
export interface FittedSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered size of a video of the given intrinsic dimensions once it has
|
||||
* been fitted into the container - i.e. scaled (up or down) so that it touches
|
||||
* the container on its constraining axis, with the other axis letter/pillar
|
||||
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
|
||||
*/
|
||||
export function fittedVideoSize(
|
||||
intrinsicWidth: number,
|
||||
intrinsicHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
): FittedSize {
|
||||
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const scale = Math.min(containerWidth / intrinsicWidth, containerHeight / intrinsicHeight);
|
||||
|
||||
return {
|
||||
width: intrinsicWidth * scale,
|
||||
height: intrinsicHeight * scale,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
/** A minimal fake <video> element that records mutations and fires events. */
|
||||
function makeFakeVideo() {
|
||||
const listeners: Record<string, Array<() => void>> = {};
|
||||
@@ -54,7 +71,7 @@ function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBr
|
||||
setSeekOffset: vi.fn((o: number) => {
|
||||
offset = o;
|
||||
}),
|
||||
setStreamUrl: vi.fn(),
|
||||
setStreamSelection: vi.fn(),
|
||||
destroyHls: vi.fn(),
|
||||
getMediaSourceId: () => "msid-1",
|
||||
...overrides,
|
||||
@@ -184,7 +201,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
|
||||
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
|
||||
video.paused = false; // was playing → should resume
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 120);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
||||
|
||||
// Teardown happened synchronously before the awaited canplay wait.
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
@@ -194,7 +211,9 @@ describe("Html5PlayerAdapter", () => {
|
||||
|
||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
|
||||
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
|
||||
);
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
@@ -217,7 +236,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
*/
|
||||
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 1200);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||
@@ -238,7 +257,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
/** A reload to the very start has nothing to seek to; it must not stall. */
|
||||
it("reloadSource() at position 0 does not wait for a seek", async () => {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 0);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
await p; // resolves without any "seeked" event
|
||||
@@ -258,7 +277,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 120);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
||||
const assertion = expect(p).rejects.toThrow(/canplay/i);
|
||||
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
|
||||
await assertion;
|
||||
@@ -270,7 +289,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
|
||||
it("reloadSource() does not resume when it was paused", async () => {
|
||||
video.paused = true;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 30);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
|
||||
* implementation. It owns the high-level control surface for an HTML5 `<video>`
|
||||
@@ -24,6 +25,39 @@ import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("Html5PlayerAdapter");
|
||||
|
||||
/**
|
||||
* The selection for a plain `load(url)` call.
|
||||
*
|
||||
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
|
||||
* When it does not — a local file, a live stream, a direct URL — the transport
|
||||
* is inferred *once, here*, from what the caller already knows rather than from
|
||||
* the URL text: a local path is a local file, and anything the backend flagged
|
||||
* as transcoded is HLS, because every transcode this app requests is HLS.
|
||||
*
|
||||
* This is the one place a fallback is tolerable, and it is explicitly a
|
||||
* fallback: the negotiated path never reaches it.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
|
||||
if (options.selection) return options.selection;
|
||||
const transport: StreamSelection["transport"] = options.isLocalFile
|
||||
? { type: "localFile" }
|
||||
: options.needsTranscoding
|
||||
? { type: "hls" }
|
||||
: { type: "progressive" };
|
||||
return {
|
||||
url: streamUrl,
|
||||
transport,
|
||||
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: options.mediaSourceId ?? null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: options.needsTranscoding,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow seam the owning component provides so the adapter can execute the
|
||||
* element/HLS-coupled parts of a control action without re-implementing the
|
||||
@@ -36,8 +70,16 @@ export interface Html5ElementBridge {
|
||||
/** Current seek offset (seconds) for transcoded streams. */
|
||||
getSeekOffset(): number;
|
||||
setSeekOffset(offset: number): void;
|
||||
/** Update the stream URL the component renders (triggers its HLS $effect). */
|
||||
setStreamUrl(url: string): void;
|
||||
/**
|
||||
* Update the stream the component renders (triggers its HLS $effect).
|
||||
*
|
||||
* Carries the whole [`StreamSelection`], not just the URL: the component's
|
||||
* effect has to know the transport to choose a loader, and deriving that from
|
||||
* the URL is the substring check DR-225 removes.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
setStreamSelection(selection: StreamSelection): void;
|
||||
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
|
||||
destroyHls(): void;
|
||||
/** Media source id for seek/audio-track URLs. */
|
||||
@@ -86,12 +128,13 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
this.attachedElement = element;
|
||||
}
|
||||
|
||||
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
|
||||
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||
// The component's reactive HLS $effect performs the actual attach/load when
|
||||
// the stream URL is set; loading is therefore driven by setStreamUrl. The
|
||||
// component's canplay/frag-buffered path reports readiness through the host.
|
||||
// the selection is set; loading is therefore driven by setStreamSelection.
|
||||
// The component's canplay/frag-buffered path reports readiness through the
|
||||
// host.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamUrl(streamUrl);
|
||||
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
|
||||
this.host.onState("loading");
|
||||
}
|
||||
|
||||
@@ -171,12 +214,12 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
*
|
||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||
*/
|
||||
async reloadSource(url: string, positionSeconds: number): Promise<void> {
|
||||
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) {
|
||||
// Still update the stream URL so the component's HLS $effect can pick it up.
|
||||
// Still update the selection so the component's HLS $effect can pick it up.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamUrl(url);
|
||||
this.bridge.setStreamSelection(selection);
|
||||
return;
|
||||
}
|
||||
const wasPlaying = !el.paused;
|
||||
@@ -189,7 +232,7 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
// The reloaded stream begins at the item's zero, so there is no base to add.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamUrl(url);
|
||||
this.bridge.setStreamSelection(selection);
|
||||
// A source that never becomes playable is a failed reload, not a slow one:
|
||||
// the caller (quality switch, transcoded seek) has to know so it can revert
|
||||
// its selection and surface the error instead of leaving the UI claiming a
|
||||
|
||||
@@ -28,6 +28,23 @@ vi.mock("$lib/api/bindings", () => ({
|
||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
function makeHost(): AdapterHost {
|
||||
return {
|
||||
onState: vi.fn(),
|
||||
@@ -67,7 +84,7 @@ describe("NativePlayerAdapter", () => {
|
||||
it("records position on seek/reload primitives (backend does the real work)", async () => {
|
||||
await adapter.seekElement(55, 0);
|
||||
expect(adapter.getPosition()).toBe(55);
|
||||
await adapter.reloadSource("ignored", 200);
|
||||
await adapter.reloadSource(testSelection("ignored"), 200);
|
||||
expect(adapter.getPosition()).toBe(200);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation.
|
||||
*
|
||||
@@ -89,7 +90,7 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
||||
* performed the reload+seek internally as part of the seek decision; nothing
|
||||
* to do on the frontend beyond recording position.
|
||||
*/
|
||||
async reloadSource(_url: string, offset: number): Promise<void> {
|
||||
async reloadSource(_selection: StreamSelection, offset: number): Promise<void> {
|
||||
this.position = offset;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* PlayerAdapter contract — the decoupled boundary between the UI/backend and a
|
||||
* concrete video player implementation (Linux HTML5+hls.js, or Android native).
|
||||
@@ -42,6 +43,19 @@ export interface PlayerLoadOptions {
|
||||
knownDuration: number;
|
||||
/** Subtitle tracks available for this media. */
|
||||
subtitleTracks: SubtitleTrackInput[];
|
||||
/**
|
||||
* The backend's decision about this stream, when it made one.
|
||||
*
|
||||
* Present for anything negotiated through `repository_get_stream_selection`.
|
||||
* Null for the paths that never negotiate — a local file, a live channel, a
|
||||
* plugin's direct URL — where the adapter falls back to what the other
|
||||
* options already say rather than to reading the URL.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
selection?: StreamSelection | null;
|
||||
/** The source is a file on disk (or the loopback server in front of one). */
|
||||
isLocalFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +120,16 @@ export interface PlayerAdapter {
|
||||
seekElement(positionSeconds: number, offset: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Compound reload: swap to `url` and resume at `offset` seconds. Runs the
|
||||
* invariant mechanical sequence for this platform (html5: pause → hls teardown
|
||||
* → clear src → set new url → wait ready → resume; native: ExoPlayer setMediaItem
|
||||
* + seekTo). No decision is made here — the backend already decided to reload.
|
||||
* Compound reload: swap to `selection` and resume at `offset` seconds. Runs
|
||||
* the invariant mechanical sequence for this platform (html5: pause → hls
|
||||
* teardown → clear src → set new selection → wait ready → resume; native:
|
||||
* ExoPlayer setMediaItem + seekTo). No decision is made here — the backend
|
||||
* already decided to reload, and `selection.transport` says how to open it, so
|
||||
* no adapter has to infer that from the URL.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
reloadSource(url: string, offset: number): Promise<void>;
|
||||
reloadSource(selection: StreamSelection, offset: number): Promise<void>;
|
||||
|
||||
setVolume(volume: number): void; // 0..1
|
||||
setMuted(muted: boolean): void;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
|
||||
* element on platforms with no native audio backend (currently Windows).
|
||||
@@ -104,8 +105,8 @@ export class WebviewAudioAdapter implements PlayerAdapter {
|
||||
}
|
||||
|
||||
/** No transcode-reload concept for direct audio; treat as a fresh load. */
|
||||
async reloadSource(url: string, offset: number): Promise<void> {
|
||||
await this.load(url, {
|
||||
async reloadSource(selection: StreamSelection, offset: number): Promise<void> {
|
||||
await this.load(selection.url, {
|
||||
mediaId: "",
|
||||
mediaSourceId: null,
|
||||
needsTranscoding: false,
|
||||
|
||||
+20
-10
@@ -22,6 +22,7 @@ import type {
|
||||
PlayAlbumTrackRequest,
|
||||
PlayItemRequest,
|
||||
StreamingQuality,
|
||||
StreamSelection,
|
||||
} from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { PlayerAdapter } from "./adapters/types";
|
||||
@@ -150,12 +151,12 @@ async function seekVideo(
|
||||
audioTrackIndex,
|
||||
adapter.kind === "html5",
|
||||
)) as any;
|
||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||
// Serde keeps `seek_offset` snake_case (only the "strategy" tag is camelCase).
|
||||
if (response.strategy === "reloadStream") {
|
||||
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
|
||||
// the element's clock: the reloaded stream starts at the item's zero since
|
||||
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
|
||||
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds);
|
||||
await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
|
||||
} else {
|
||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||
}
|
||||
@@ -182,26 +183,32 @@ async function switchAudioTrack(
|
||||
mediaSourceId,
|
||||
)) as any;
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.new_url!, response.position!);
|
||||
await adapter.reloadSource(response.selection, response.position!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the bandwidth ceiling of the video playing now. The backend re-opens
|
||||
* the stream at the new quality and decides who reloads: it handles a native
|
||||
* backend itself, and hands HTML5 a URL for the same `reloadSource` primitive
|
||||
* the audio-track switch uses. Requires an active video adapter.
|
||||
* backend itself, and hands HTML5 a selection for the same `reloadSource`
|
||||
* primitive the audio-track switch uses. Requires an active video adapter.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
* The change applies to **this playback only** — the backend sets a per-playback
|
||||
* override that the next item clears, leaving the durable Settings default
|
||||
* alone. Returns the negotiated selection so the caller can show what it
|
||||
* actually got, which is not always what was asked for: a ceiling above the
|
||||
* source bitrate is the source.
|
||||
*
|
||||
* TRACES: UR-074, UR-079 | DR-162, DR-226
|
||||
*/
|
||||
async function setStreamQuality(
|
||||
quality: StreamingQuality,
|
||||
currentPosition: number | null,
|
||||
mediaSourceId: string | null,
|
||||
audioTrackIndex: number | null,
|
||||
): Promise<void> {
|
||||
): Promise<StreamSelection | null> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) return;
|
||||
if (!adapter) return null;
|
||||
const response = (await commands.playerSetStreamQuality(
|
||||
requireHandle(),
|
||||
quality,
|
||||
@@ -210,10 +217,13 @@ async function setStreamQuality(
|
||||
mediaSourceId,
|
||||
audioTrackIndex,
|
||||
)) as any;
|
||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0);
|
||||
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
|
||||
return response.selection;
|
||||
}
|
||||
// The native backend reloaded itself, but still reports what it opened — the
|
||||
// caller needs it to show the rung actually in force.
|
||||
return response.selection ?? null;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { downloadedFilePath, resolveVideoSource } from "./localSource";
|
||||
import { downloadedFilePath } from "./localSource";
|
||||
|
||||
describe("downloadedFilePath", () => {
|
||||
// The download worker rewrites `downloads.file_path` to the absolute path it
|
||||
@@ -29,74 +29,3 @@ describe("downloadedFilePath", () => {
|
||||
expect(downloadedFilePath("C:\\Users\\u\\AppData\\jellytau", stored)).toBe(stored);
|
||||
});
|
||||
});
|
||||
|
||||
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
|
||||
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
|
||||
|
||||
describe("resolveVideoSource", () => {
|
||||
it("plays the downloaded file when one exists", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: "/home/u/.local/share/jellytau/movie.mp4",
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.isLocal).toBe(true);
|
||||
expect(decision.url).toBe(toAssetUrl("/home/u/.local/share/jellytau/movie.mp4"));
|
||||
});
|
||||
|
||||
it("never marks a local file as needing transcoding, even when the remote did", () => {
|
||||
// The transcoded path re-requests a whole new stream URL on every seek.
|
||||
// A local file seeks natively; sending it down that route would ask the
|
||||
// server for a stream we deliberately avoided.
|
||||
const decision = resolveVideoSource({
|
||||
localPath: "/downloads/film.mkv",
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.needsTranscoding).toBe(false);
|
||||
});
|
||||
|
||||
it("streams when nothing is downloaded, preserving the transcoding flag", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: null,
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
url: "https://server/Videos/abc/master.m3u8",
|
||||
needsTranscoding: true,
|
||||
isLocal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("streams a direct-play remote without claiming it transcodes", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: null,
|
||||
remoteUrl: "https://server/Videos/abc/stream.mp4",
|
||||
remoteNeedsTranscoding: false,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.needsTranscoding).toBe(false);
|
||||
expect(decision.isLocal).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to streaming for a blank path rather than building a dead asset URL", () => {
|
||||
for (const localPath of ["", " "]) {
|
||||
const decision = resolveVideoSource({
|
||||
localPath,
|
||||
remoteUrl: "https://server/stream",
|
||||
remoteNeedsTranscoding: false,
|
||||
toAssetUrl,
|
||||
});
|
||||
expect(decision.isLocal).toBe(false);
|
||||
expect(decision.url).toBe("https://server/stream");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
/**
|
||||
* Choosing between a downloaded file and a server stream for video playback.
|
||||
*
|
||||
* Audio has preferred local files since the queue is built (the Rust queue
|
||||
* resolves `MediaSource::Local`), but video asks the repository for a stream URL
|
||||
* and never consults `downloads` — so a downloaded film was streamed anyway,
|
||||
* spending bandwidth that had already been spent and failing outright offline.
|
||||
*
|
||||
* Pure so it can be unit-tested: the component only supplies the two inputs and
|
||||
* the asset-URL converter.
|
||||
*
|
||||
* TRACES: UR-071 | DR-123 | UT-118
|
||||
*/
|
||||
|
||||
export interface VideoSourceInputs {
|
||||
/** Absolute on-disk path of a completed download, or null to stream. */
|
||||
localPath: string | null;
|
||||
/** Stream URL the repository resolved (already transcoded if it had to be). */
|
||||
remoteUrl: string;
|
||||
/** Whether the *remote* stream is a transcode. */
|
||||
remoteNeedsTranscoding: boolean;
|
||||
/** Usually Tauri's `convertFileSrc`; injected so this module stays pure. */
|
||||
toAssetUrl: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface VideoSourceDecision {
|
||||
/** What to hand the `<video>` element. */
|
||||
url: string;
|
||||
/**
|
||||
* Local files are never transcodes, so this is always false for them. It
|
||||
* matters because the transcoded path re-requests a whole new stream URL on
|
||||
* every seek; a local file seeks natively and must not go down that route.
|
||||
*/
|
||||
needsTranscoding: boolean;
|
||||
/** True when playing from disk — for logging and the offline badge. */
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
/** Absolute on POSIX (`/…`), Windows (`C:\…`, `C:/…`) or a UNC share (`\\…`). */
|
||||
function isAbsolute(path: string): boolean {
|
||||
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
|
||||
@@ -57,15 +19,3 @@ function isAbsolute(path: string): boolean {
|
||||
export function downloadedFilePath(storageRoot: string, filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : `${storageRoot}/${filePath}`;
|
||||
}
|
||||
|
||||
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
|
||||
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
|
||||
|
||||
// Treat blank/whitespace paths as absent — a malformed `downloads` row must
|
||||
// not produce an asset URL pointing at nothing.
|
||||
if (localPath && localPath.trim() !== "") {
|
||||
return { url: toAssetUrl(localPath), needsTranscoding: false, isLocal: true };
|
||||
}
|
||||
|
||||
return { url: remoteUrl, needsTranscoding: remoteNeedsTranscoding, isLocal: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The loader is chosen from the backend's `transport` tag, never from the URL.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225 | UT-214
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
|
||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
||||
|
||||
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
|
||||
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
|
||||
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
|
||||
|
||||
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
|
||||
return { url, transport };
|
||||
}
|
||||
|
||||
describe("videoLoaderFor", () => {
|
||||
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
|
||||
"hlsjs",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
||||
"nativeHls",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads a progressive stream directly", () => {
|
||||
expect(
|
||||
videoLoaderFor(
|
||||
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
|
||||
MODERN,
|
||||
),
|
||||
).toBe("direct");
|
||||
});
|
||||
|
||||
it("loads a local file directly", () => {
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
|
||||
).toBe("direct");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The two cases the `.m3u8` substring check gets wrong. These are the
|
||||
// reason the field exists; both fail against a URL-sniffing implementation.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
|
||||
// A direct play served from a path containing the substring — nothing stops
|
||||
// a server, a proxy, or a local cache from producing this.
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
|
||||
).toBe("direct");
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
|
||||
).toBe("direct");
|
||||
});
|
||||
|
||||
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
|
||||
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
|
||||
// DASH or query-routed playlist endpoint never would.
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
|
||||
"hlsjs",
|
||||
);
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
|
||||
).toBe("nativeHls");
|
||||
});
|
||||
|
||||
it("falls back to direct when HLS is requested but nothing can play it", () => {
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
|
||||
"direct",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elementSrcFor", () => {
|
||||
it("empties the element's src only when hls.js drives it", () => {
|
||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
|
||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
||||
"https://s/master.m3u8",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the src for a progressive stream that looks like a playlist", () => {
|
||||
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
|
||||
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Which loader opens a stream in the webview `<video>` element.
|
||||
*
|
||||
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested — the
|
||||
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225 | UT-214
|
||||
*/
|
||||
|
||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
||||
|
||||
/** How the element should be fed. */
|
||||
export type VideoLoader =
|
||||
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
|
||||
| "hlsjs"
|
||||
/** The element loads the playlist itself (Safari/WebKit native HLS). */
|
||||
| "nativeHls"
|
||||
/** The element loads the URL directly — a progressive file or a local one. */
|
||||
| "direct";
|
||||
|
||||
/** What the running browser can do, passed in so the decision stays pure. */
|
||||
export interface LoaderCapabilities {
|
||||
/** `Hls.isSupported()` */
|
||||
hlsJsSupported: boolean;
|
||||
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
|
||||
nativeHlsSupported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the loader from the backend's tagged `transport`.
|
||||
*
|
||||
* This used to read `url.includes(".m3u8")`, in two places in
|
||||
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
|
||||
* re-deriving the answer here by substring match is a domain fact reconstructed
|
||||
* in the presentation layer — the same error as leaking item-type taxonomy, and
|
||||
* one that fails silently in both directions: a progressive file served from a
|
||||
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
|
||||
* without it does not.
|
||||
*
|
||||
* The transport is the *stream's* property; whether a given loader exists is the
|
||||
* *browser's*. Only the second is decided here.
|
||||
*/
|
||||
export function videoLoaderFor(
|
||||
selection: Pick<StreamSelection, "url" | "transport">,
|
||||
capabilities: LoaderCapabilities,
|
||||
): VideoLoader {
|
||||
return loaderForTransport(selection.transport.type, capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same decision, taken from the transport *tag* alone.
|
||||
*
|
||||
* Exists because a Svelte `$effect` that reads the whole selection re-runs
|
||||
* whenever the selection **object** is replaced — even with an identical URL and
|
||||
* transport — and the HLS effect's teardown/rebuild is not idempotent: it
|
||||
* destroys the hls.js instance and reattaches, which leaves the element with no
|
||||
* video until something forces another cycle. The pre-DR-225 code read a plain
|
||||
* URL *string*, so re-assigning the same value was a no-op and the effect stayed
|
||||
* put. Passing primitives restores that.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225 | UT-214
|
||||
*/
|
||||
export function loaderForTransport(
|
||||
transport: Transport["type"],
|
||||
capabilities: LoaderCapabilities,
|
||||
): VideoLoader {
|
||||
if (transport !== "hls") {
|
||||
// Progressive and local files are what the element loads natively. No
|
||||
// MediaSource, no playlist parsing.
|
||||
return "direct";
|
||||
}
|
||||
if (capabilities.hlsJsSupported) return "hlsjs";
|
||||
if (capabilities.nativeHlsSupported) return "nativeHls";
|
||||
// Nothing here can parse a playlist. Handing the URL to the element is very
|
||||
// likely to fail, but it is the only remaining move and it surfaces a real
|
||||
// media error rather than silently doing nothing.
|
||||
return "direct";
|
||||
}
|
||||
|
||||
/** Convenience for the template: does the element's `src` stay empty? */
|
||||
export function elementSrcFor(
|
||||
selection: Pick<StreamSelection, "url" | "transport">,
|
||||
capabilities: LoaderCapabilities,
|
||||
): string {
|
||||
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
|
||||
}
|
||||
|
||||
export type { Transport };
|
||||
@@ -64,6 +64,49 @@ function createAuthStore() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository, waiting for session restore rather than failing the instant
|
||||
* it is asked.
|
||||
*
|
||||
* `getRepository()` throws immediately, which is right for a click handler —
|
||||
* the user is present and an error is honest. It is wrong for anything that
|
||||
* runs *on mount*: the session is restored asynchronously at startup, so a
|
||||
* page that loads before that finishes gets "Not connected to a server" and
|
||||
* shows a fatal error for a session that was about to arrive. The player page
|
||||
* hit this, where the symptom is a playback error on a perfectly good stream.
|
||||
*
|
||||
* Resolves as soon as the repository exists, rejects only if it genuinely has
|
||||
* not appeared — so a real logged-out state still surfaces, just not as a race.
|
||||
*
|
||||
* TRACES: UR-002 | DR-013
|
||||
*/
|
||||
async function waitForRepository(timeoutMs = 5000): Promise<RepositoryClient> {
|
||||
if (repository) return repository;
|
||||
|
||||
return new Promise<RepositoryClient>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
fn();
|
||||
};
|
||||
|
||||
// Every store change is a chance the session landed. `subscribe` fires
|
||||
// synchronously on registration, which also covers the case where it
|
||||
// arrived between the check above and here.
|
||||
const unsubscribe = subscribe(() => {
|
||||
if (repository) finish(() => resolve(repository as RepositoryClient));
|
||||
});
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish(() => reject(new Error("Not connected to a server"))),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize event listeners from Rust backend.
|
||||
* These should be called once during app initialization.
|
||||
@@ -572,6 +615,7 @@ function createAuthStore() {
|
||||
logout,
|
||||
clearError,
|
||||
getRepository,
|
||||
waitForRepository,
|
||||
getCurrentSession,
|
||||
getUserId,
|
||||
getServerUrl,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Waiting for the repository rather than racing it.
|
||||
*
|
||||
* The defect: the player page asks for the repository *on mount*, but the
|
||||
* session is restored asynchronously at startup. Losing that race produced
|
||||
* "Not connected to a server" as a fatal playback error for a stream that was
|
||||
* perfectly fine.
|
||||
*
|
||||
* These test the waiting contract itself rather than the auth store's internals,
|
||||
* because the contract is the part the player depends on: resolve as soon as it
|
||||
* exists, still reject when it genuinely is not there, and never settle twice.
|
||||
*
|
||||
* TRACES: UR-002, UR-004 | DR-013 | UT-215
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* The shape `waitForRepository` is built on: a store you can subscribe to, and
|
||||
* a value that appears at some later point. Mirrors the real implementation
|
||||
* without dragging in Tauri.
|
||||
*/
|
||||
function makeWaiter() {
|
||||
let repository: object | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const subscribe = (fn: Listener) => {
|
||||
listeners.add(fn);
|
||||
fn(); // stores fire synchronously on subscribe
|
||||
return () => listeners.delete(fn);
|
||||
};
|
||||
const publish = (value: object | null) => {
|
||||
repository = value;
|
||||
listeners.forEach((fn) => fn());
|
||||
};
|
||||
|
||||
async function waitForRepository(timeoutMs = 5000): Promise<object> {
|
||||
if (repository) return repository;
|
||||
return new Promise<object>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
fn();
|
||||
};
|
||||
const unsubscribe = subscribe(() => {
|
||||
if (repository) finish(() => resolve(repository as object));
|
||||
});
|
||||
const timer = setTimeout(
|
||||
() => finish(() => reject(new Error("Not connected to a server"))),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return { waitForRepository, publish, listenerCount: () => listeners.size };
|
||||
}
|
||||
|
||||
describe("waitForRepository", () => {
|
||||
it("resolves immediately when the session is already restored", async () => {
|
||||
const w = makeWaiter();
|
||||
const repo = {};
|
||||
w.publish(repo);
|
||||
await expect(w.waitForRepository(50)).resolves.toBe(repo);
|
||||
});
|
||||
|
||||
it("resolves when the session arrives later — the race the player lost", async () => {
|
||||
const w = makeWaiter();
|
||||
const repo = {};
|
||||
const pending = w.waitForRepository(1000);
|
||||
// Nothing yet; the page has already mounted and asked.
|
||||
setTimeout(() => w.publish(repo), 10);
|
||||
await expect(pending).resolves.toBe(repo);
|
||||
});
|
||||
|
||||
it("still rejects when there genuinely is no session", async () => {
|
||||
vi.useFakeTimers();
|
||||
const w = makeWaiter();
|
||||
const pending = w.waitForRepository(500);
|
||||
const assertion = expect(pending).rejects.toThrow("Not connected to a server");
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
await assertion;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("unsubscribes once settled, so a later change cannot resolve it twice", async () => {
|
||||
const w = makeWaiter();
|
||||
const repo = {};
|
||||
const pending = w.waitForRepository(1000);
|
||||
expect(w.listenerCount()).toBe(1);
|
||||
w.publish(repo);
|
||||
await pending;
|
||||
expect(w.listenerCount()).toBe(0);
|
||||
// A further change must not throw or re-settle.
|
||||
expect(() => w.publish(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not leave a pending timer that fires after success", async () => {
|
||||
vi.useFakeTimers();
|
||||
const w = makeWaiter();
|
||||
const repo = {};
|
||||
const pending = w.waitForRepository(200);
|
||||
w.publish(repo);
|
||||
await expect(pending).resolves.toBe(repo);
|
||||
// If the timeout were still armed it would reject an already-settled
|
||||
// promise, which surfaces as an unhandled rejection rather than a failure.
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { downloadedFilePath, resolveVideoSource } from "$lib/player/localSource";
|
||||
import type { PlayQueueRequest } from "$lib/api/bindings";
|
||||
import { downloadedFilePath } from "$lib/player/localSource";
|
||||
import type { PlayQueueRequest, StreamSelection } from "$lib/api/bindings";
|
||||
import type { MediaItem, MediaKind } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { library } from "$lib/stores/library";
|
||||
@@ -76,7 +76,15 @@
|
||||
const hasNext = $derived($hasNextStore);
|
||||
const hasPrevious = $derived($hasPreviousStore);
|
||||
let currentMedia = $state<MediaItem | null>(null);
|
||||
let streamUrl = $state<string | null>(null);
|
||||
/**
|
||||
* What to play, as the backend decided it. Null while still resolving.
|
||||
*
|
||||
* Replaces a bare URL string: the transport travels with it, so neither this
|
||||
* page nor VideoPlayer has to work out whether the URL is a playlist.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
let selection = $state<StreamSelection | null>(null);
|
||||
let mediaSourceId = $state<string | null>(null);
|
||||
let isVideo = $state(false);
|
||||
let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
|
||||
@@ -94,7 +102,7 @@
|
||||
|
||||
// Which player component to render. Video without a stream URL is "pending"
|
||||
// (still resolving), never audio — see playerSurface.ts.
|
||||
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl }));
|
||||
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl: selection?.url ?? null }));
|
||||
|
||||
onMount(() => {
|
||||
// Start position polling (only for audio via MPV backend)
|
||||
@@ -308,17 +316,17 @@
|
||||
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
|
||||
log.debug("loadAndPlay: Full local path:", fullPath);
|
||||
|
||||
// Serve the file over the loopback media server rather than the asset
|
||||
// protocol: the asset protocol answers a range-less request with the
|
||||
// entire file, so a downloaded film never finished loading. Rust mints
|
||||
// the URL (it holds the port and the per-session token).
|
||||
// TRACES: UR-071 | DR-137
|
||||
const localUrl = await commands.mediaLocalUrl(fullPath);
|
||||
log.debug("loadAndPlay: Local media URL resolved");
|
||||
|
||||
if (isVideo) {
|
||||
// Local video files don't need transcoding and support native seeking
|
||||
streamUrl = localUrl;
|
||||
// Served over the loopback media server rather than the asset
|
||||
// protocol: the asset protocol answers a range-less request with the
|
||||
// entire file, so a downloaded film never finished loading. Rust mints
|
||||
// the URL (it holds the port and the per-session token) and states the
|
||||
// transport with it.
|
||||
//
|
||||
// A downloaded file is a direct play over a local transport, and Rust
|
||||
// says so rather than this page assuming it.
|
||||
// TRACES: UR-071 | DR-137, DR-225
|
||||
selection = await commands.mediaLocalSelection(fullPath);
|
||||
videoNeedsTranscoding = false;
|
||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||
@@ -346,7 +354,12 @@
|
||||
} else {
|
||||
// Online playback - get playback info from server
|
||||
isOfflinePlayback = false;
|
||||
const repo = auth.getRepository();
|
||||
// Wait for session restore rather than failing on a race: this runs on
|
||||
// mount, and at startup (or after a hot reload) the repository may be a
|
||||
// few hundred milliseconds behind. Failing instantly showed "Not
|
||||
// connected to a server" as a *playback* error for a stream that was
|
||||
// fine. TRACES: UR-002, UR-004 | DR-013
|
||||
const repo = await auth.waitForRepository();
|
||||
|
||||
if (isLive) {
|
||||
// Live TV channels must be "opened" before streaming; the server returns
|
||||
@@ -355,7 +368,19 @@
|
||||
const liveInfo = await repo.openLiveStream(id);
|
||||
log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
|
||||
mediaSourceId = liveInfo.mediaSourceId;
|
||||
streamUrl = liveInfo.streamUrl;
|
||||
selection = {
|
||||
url: liveInfo.streamUrl,
|
||||
// Rust's verdict, not a guess from the URL.
|
||||
transport: liveInfo.transport,
|
||||
playbackKind: { type: "transcode" },
|
||||
rendition: null,
|
||||
// A live channel has no ladder to offer: there is no source file to
|
||||
// measure and no rendition to re-negotiate against.
|
||||
available: [],
|
||||
mediaSourceId: liveInfo.mediaSourceId,
|
||||
playSessionId: liveInfo.playSessionId,
|
||||
needsTranscoding: true,
|
||||
};
|
||||
videoNeedsTranscoding = true;
|
||||
videoInitialPosition = 0;
|
||||
isPlaying = true;
|
||||
@@ -363,46 +388,46 @@
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("loadAndPlay: Getting playback info");
|
||||
const playbackInfo = await repo.getPlaybackInfo(id);
|
||||
log.debug("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
||||
|
||||
if (isVideo) {
|
||||
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
|
||||
log.debug(
|
||||
"loadAndPlay: Using video stream, directPlay:",
|
||||
playbackInfo.directPlay,
|
||||
"needsTranscoding:",
|
||||
playbackInfo.needsTranscoding,
|
||||
);
|
||||
mediaSourceId = playbackInfo.mediaSourceId;
|
||||
|
||||
// Prefer a completed download over streaming. Audio has done this
|
||||
// since the queue is built; video previously always streamed, so a
|
||||
// downloaded film re-spent bandwidth already spent and would not play
|
||||
// at all offline. Rust returns null when nothing is downloaded or the
|
||||
// file has gone, so this falls back to the server on its own.
|
||||
// TRACES: UR-071 | DR-123
|
||||
// A downloaded file is served over the loopback media server, not the
|
||||
// asset protocol — see DR-137. The URL is minted up front because
|
||||
// resolveVideoSource stays pure/synchronous.
|
||||
// TRACES: UR-071 | DR-123, DR-137
|
||||
//
|
||||
// Checked *first* so the streaming path below negotiates exactly once:
|
||||
// asking for a `PlaybackInfo` and then a stream selection meant two
|
||||
// negotiations per load, and each one claims a transcode identity and
|
||||
// retires the previous — so the server started a job only to be told
|
||||
// to stop it a moment later. Observed in the log as a pair of
|
||||
// `[StreamSelection]` lines for one play.
|
||||
//
|
||||
// TRACES: UR-071 | DR-123, DR-137, DR-225
|
||||
const localPath = await commands.playerLocalMediaPath(id);
|
||||
const localUrl = localPath ? await commands.mediaLocalUrl(localPath) : null;
|
||||
const source = resolveVideoSource({
|
||||
localPath,
|
||||
remoteUrl: playbackInfo.streamUrl,
|
||||
remoteNeedsTranscoding: playbackInfo.needsTranscoding,
|
||||
toAssetUrl: () => localUrl ?? "",
|
||||
});
|
||||
|
||||
streamUrl = source.url;
|
||||
videoNeedsTranscoding = source.needsTranscoding;
|
||||
log.debug(
|
||||
source.isLocal
|
||||
? "loadAndPlay: Playing downloaded file from disk"
|
||||
: `loadAndPlay: Using stream URL: ${streamUrl}`,
|
||||
);
|
||||
if (localPath) {
|
||||
// A downloaded file is a direct play over a local transport, served
|
||||
// by the loopback media server rather than the asset protocol
|
||||
// (DR-137). Its media-source id still comes from the server, since
|
||||
// that is what subtitle URLs are keyed by.
|
||||
selection = await commands.mediaLocalSelection(localPath);
|
||||
videoNeedsTranscoding = false;
|
||||
mediaSourceId = (await repo.getPlaybackInfo(id)).mediaSourceId;
|
||||
log.debug("loadAndPlay: Playing downloaded file from disk");
|
||||
} else {
|
||||
// Rust negotiates direct play vs direct stream vs transcode against
|
||||
// the device profile and the ceiling in force, and returns the
|
||||
// transport and the media-source id with it. This page no longer
|
||||
// decides — or separately asks for — any of that.
|
||||
// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228
|
||||
selection = await repo.getStreamSelection(id, null, null);
|
||||
mediaSourceId = selection.mediaSourceId;
|
||||
// Rust's own verdict — "which kinds count as transcoding" is a
|
||||
// domain rule, and a direct *stream* is a remux that does not.
|
||||
videoNeedsTranscoding = selection.needsTranscoding;
|
||||
log.debug(
|
||||
`loadAndPlay: ${selection.playbackKind.type} over ${selection.transport.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Set initial position for the video player to seek to after load.
|
||||
// Use explicit startPosition, or fall back to retrieved progress.
|
||||
@@ -847,10 +872,10 @@
|
||||
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if surface === "video" && streamUrl}
|
||||
{:else if surface === "video" && selection}
|
||||
<VideoPlayer
|
||||
media={currentMedia}
|
||||
{streamUrl}
|
||||
{selection}
|
||||
mediaSourceId={mediaSourceId ?? undefined}
|
||||
initialPosition={videoInitialPosition}
|
||||
needsTranscoding={videoNeedsTranscoding}
|
||||
|
||||
Reference in New Issue
Block a user